Android topics — 26: מתי באמת צריך SurfaceView או ContentProvider?


לולאת ציור למשחק, בעלות על Surface וגבול שיתוף בין אפליקציות

מפת המעבדות · Canvas ב־CollectCircles

בסוף המעבדה כדור נע ומתנגש בגבולות על SurfaceView; נגיעה ממקמת אותו מחדש. שרשור ציור יחיד פועל רק כשה־Activity וה־Surface פעילים, ובדיקת מכשיר מאשרת שמספר הפריימים מפסיק לגדול ב־Pause וחוזר לגדול ב־Resume. ה־Surface מגשימה צורך של לולאת ציור רציפה במשחק קטן.

בסיס ההשוואה בפרויקט topics הוא master, וענף התוצאה הוא codex/surfaceview-game-loop. השיעור מלמד התמחות אחת לעומק. ContentProvider מוסבר בהמשך כחלופה למטרה אחרת לחלוטין — שיתוף נתונים בין אפליקציות — ואין הצדקה להוסיף אותו למשחק הכדור רק כדי לצבור רכיב Android נוסף.

קודם שואלים מה הבעיה

צורך אמיתי כלי מתאים למה?
ציור שמשתנה רק אחרי לחיצה Custom View + onDraw()/invalidate() פשוט ומשתלב במחזור הציור הרגיל
פריימים רציפים עם חישוב פיזיקה בשרשור ייעודי SurfaceView + SurfaceHolder בעלות ברורה על משטח ציור שנגיש לשרשור רקע
שיתוף רשומות עם אפליקציה אחרת באמצעות content:// והרשאות ContentProvider חוזה query/insert/delete בין תהליכים
נתון שרק האפליקציה עצמה צריכה Room/קובץ פרטי אין צורך בחוזה שיתוף חיצוני

ב־CollectCircles כבר לומדים ציור מותאם ב־Canvas. עבור מסך קטן שמתעדכן על אירוע, התחילו משם. תיעוד ציור מותאם מציג onDraw. המעבדה הנוכחית מתחילה כשיש צורך ייחודי: משחק שמצייר שוב ושוב, בערך אחת ל־16ms, ובעל מחזור חיים של Surface. תיעוד SurfaceView מתאר את השימוש בשרשור משני.

שני אישורים להתחלה, בעלים אחד לציור

שרשור הציור רשאי להתחיל רק כאשר המסך resumed וגם המשטח קיים. אין סדר callback אחד שאפשר להניח תמיד: לפעמים המסך פעיל לפני יצירת המשטח, ולפעמים המשטח קיים כשהמסך אינו פעיל. כל אירוע מעדכן את התנאי שלו וקורא לאותה הכרעה startIfReady. בדיקת renderer != null מונעת יצירה כפולה.

flowchart TD
    A["Activity resume"] --> R["resumed = true"]
    B["surfaceCreated"] --> S["surfaceReady = true"]
    R --> C{"Both true and no renderer?"}
    S --> C
    C -->|yes| T["Start one render thread"]
    P["pause or surfaceDestroyed"] --> E["running = false; interrupt"]
    E --> J["join: renderer has stopped"]
    J --> N["release thread reference"]

running נקרא בשרשור הציור ונכתב מ־UI, ולכן דרוש מנגנון נראות זיכרון, כאן volatile. positionLock מגן על קבוצה של ערכים: שינוי x ו־y צריך להיראות יחד. AtomicInteger מתאים למונה פריימים שנקרא משרשור בדיקה; הוא אינו מחליף נעילה של כל מיקום הכדור. אלה שלושה כלים לבעיות שונות, לא שלושה איותים לאותה בטיחות.

המהירות בפיקסלים לשנייה כפול dt בשניות נותנת מרחק בפיקסלים. בלי dt, “360 פיקסלים בכל פריים” היה משנה את מהירות המשחק עם קצב המכשיר. תקרת dt מפחיתה קפיצה אחרי השהיה ארוכה אבל משמיטה חלק מזמן הסימולציה: פשרה גלויה של המשחק הקטן. כשהמרכז מגיע לקיר בודקים גם רדיוס, כדי שהכדור כולו יישאר בתחום.

lockCanvas מקבלת משטח לציור; unlockCanvasAndPost משחררת ומפרסמת אותו. finally מבטיחה שזוג הפעולות ייסגר גם בכשל. shutdown באמצעות join ממתינה שהשרשור באמת חדל להשתמש במשטח לפני חזרת surfaceDestroyed. אין להחזיק תוך ההמתנה lock שהשרשור צריך כדי לסיים, אחרת אפשר ליצור deadlock. הלולאה חייבת להיות קצרה, להגיב לביטול ולא להמתין לפעולה מן ה־UI שמחכה לה.

עצרו ונבאו

surfaceCreated התקבלה כשה־Activity עדיין אינה resumed. האם מתחילים שרשור ציור? כתבו תחזית לפני פתיחת ההסבר, ואז הצביעו על המשתנה או התנאי בקוד שמצדיקים אותה.

בדיקת ההבנה

לא. startIfReady דורשת שני אישורים וגם שאין renderer קיימת. כשה־Activity תעבור ל־resume היא תבדוק שוב ותתחיל שרשור יחיד. pause או destruction מבטלות אחד התנאים ועוצרות את אותו שרשור לפני שחרור המשטח.

1. מציבים משטח ואזור הסבר

ב־app > res > layout > activity_main.xml החליפו את TextView היחיד בשני ילדים של ConstraintLayout id=main: TextView id=instruction בראש עם @string/ball_instruction, ומתחתיו com.example.topics.BouncingBallView id=ball ש־layout_width/layout_height שלו 0dp והוא constrained ל־top של התחתית של ההסבר, ל־bottom של ההורה ולשני הצדדים. השאירו את טיפול ה־window insets ב־Activity. ב־app > res > values > strings.xml הוסיפו ball_instruction ו־ball_description; תיאור נגישות מסביר שיש אנימציה ופעולת נגיעה, אף שבמשחק אמיתי צריך גם חלופת שליטה נגישה.

ב־MainActivity.java הוסיפו רק קריאות מחזור חיים:

/**
 * Allows drawing while the screen is active; the Surface must also be ready.
 */
@Override
protected void onResume() {
    super.onResume();
    binding.ball.resume();
}

/**
 * Stops owned drawing before the screen becomes inactive.
 */
@Override
protected void onPause() {
    binding.ball.pause();
    super.onPause();
}

ה־Activity אינה מציירת בעצמה. היא אומרת ל־View מתי המסך פעיל; ה־View אחראית למשטח ולשרשור. שימרו את שני התנאים: Activity יכולה להיות resumed בזמן שה־Surface עוד לא נוצרה, ולהפך.

2. Surface תקפה רק בין שני callbacks

צרו BouncingBallView.java ב־app > kotlin+java > com.example.topics, שיורשת SurfaceView ומממשת SurfaceHolder.Callback. בבנאי שימרו holder = getHolder(), הוסיפו holder.addCallback(this), הכינו Paint פעם אחת וחשבו רדיוס ב־dp. השתמשו בשדות resumed,‏ surfaceReady,‏ running ו־Thread renderer:

/**
 * Marks the Activity side ready and starts only if the Surface is also ready.
 */
public void resume() { resumed = true; startIfReady(); }
/**
 * Clears the Activity side and waits for owned drawing to stop.
 */
public void pause() { resumed = false; stopRenderer(); }

/**
 * Marks the Surface usable and checks whether the Activity is ready too.
 *
 * @param holder the newly available drawing surface owner
 */
@Override
public void surfaceCreated(SurfaceHolder holder) {
    surfaceReady = true;
    startIfReady();
}
/**
 * Stops and joins drawing before returning control of a disappearing Surface.
 *
 * @param holder Surface owner that must no longer be used on return
 */
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
    surfaceReady = false;
    stopRenderer();
}

/**
 * Starts at most one renderer when both independent lifecycle conditions hold.
 */
private void startIfReady() {
    if (!resumed || !surfaceReady || renderer != null) return;
    running = true;
    renderer = new Thread(this::renderLoop, "ball-renderer");
    renderer.start();
}

SurfaceHolder.Callback אומרת שהמשטח נגיש רק אחרי surfaceCreated ולפני surfaceDestroyed. בתיעוד surfaceDestroyed מודגש ששרשור ציור חייב לסיים להשתמש במשטח לפני שה־callback חוזר. לכן stopRenderer() מציבה running=false, מפריעה ל־sleep וקוראת join() לשרשור לפני איפוס ההפניה. אין לפתוח שני render threads אחרי חזרה מהירה לאפליקציה.

3. פריים: זמן, תנועה, ציור ופרסום

בלולאה בקוד המשלים זמן הפריים הוא dt בשניות מתוך System.nanoTime(), עם תקרה של 0.05f. אם תהליך הושהה, כדור לא יקפוץ בבת אחת מרחק של דקות. המהירויות vx=360,‏ vy=280 הן פיקסלים לשנייה. בכל סיבוב מוסיפים vx*dt,‏ vy*dt, הופכים את סימן המהירות כשמגיעים לקיר ומחזירים את המיקום לגבול החוקי.

Canvas canvas = null;
try {
    canvas = holder.lockCanvas();
    if (canvas != null) {
        canvas.drawColor(Color.rgb(15, 25, 45));
        synchronized (positionLock) {
            x += vx * dt;
            y += vy * dt;
            // Clamp to the visible bounds and reverse velocity at a wall.
            paint.setColor(Color.rgb(64, 210, 244));
            canvas.drawCircle(x, y, radius, paint);
        }
        frames.incrementAndGet();
    }
} finally {
    if (canvas != null) holder.unlockCanvasAndPost(canvas);
}

ה־drawColor מנקה את כל הבאפר בכל פריים; אין להניח שהוא שומר אוטומטית את הציור הישן. unlockCanvasAndPost חייבת להיקרא עבור Canvas שננעלה גם אם החישוב נכשל. Thread.sleep(Math.max(1, 16-elapsedMs)) נותנת מגבלת קצב פשוטה כדי שהלולאה לא תשרוף CPU. זו קירוב חינוכי, לא סנכרון מקצועי עם קצב המסך או מנוע משחק. תיעוד SurfaceHolder מפרט lock/post.

onTouchEvent(ACTION_DOWN) מעדכנת x/y של הכדור בתוך אותו positionLock שבו משתמש שרשור הציור. זהו הגבול בין UI thread ל־render thread. בלי נעילה, חצי עדכון או ערך ישן יכולים ליצור קפיצה. אין לגעת ב־TextView מתוך שרשור הציור; אם צריך משוב נגיש או ניקוד, שלחו state ל־UI thread.

4. בדיקה שמוכיחה שהלולאה נעצרת

כדי להשתמש ב־ActivityScenario, הוסיפו ב־Gradle Scripts > libs.versions.toml אל הטבלאות הקיימות:

# [versions]
testCore = "1.7.0"

# [libraries]
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCore" }

ב־Gradle Scripts > build.gradle.kts (Module :app), בתוך dependencies, הוסיפו androidTestImplementation(libs.androidx.test.core) ובצעו Sync. השאירו את תלות JUnit/Espresso וקובצי בדיקות התבנית. צרו את SurfaceLoopTest.java מתוך הקוד המשלים ב־app > kotlin+java > com.example.topics (androidTest). הבדיקה רצה על אמולטור/מכשיר, משום ש־Surface ומחזור חיי Activity הם התנהגות Android.

בקוד המשלים getFrameCount() קוראת AtomicInteger. SurfaceLoopTest מפעילה את MainActivity באמצעות ActivityScenario, ממתינה לפריימים, עוברת ל־Lifecycle.State.STARTED, ממתינה שוב ומאשרת שהמספר לא גדל, ואז חוזרת ל־RESUMED ומאשרת שהוא גדל. :app:connectedDebugAndroidTest עבר באמולטור. צילום מסך של האמולטור הראה כדור תכלת על רקע כהה; נגיעה מזיזה את נקודת ההתחלה שלו. נסו גם מעבר מהיר Home→אפליקציה, סיבוב מסך וכיבוי/הדלקת מסך. במוצר אמיתי שמרו גם מיקום/מהירות כאשר סיבוב מסך לא אמור לאפס משחק.

ומה לגבי ContentProvider?

ContentProvider מתאים כאשר אפליקציה אחרת צריכה לקרוא נתון דרך ContentResolver ו־URI מסוג content://authority/path, ואנחנו צריכים חוזה, MIME type, הרשאות ו־Cursor או API תואם. למשל: אפליקציית מערכת קבצים פותחת מסמך שהאפליקציה שלנו משתפת, או אפליקציית לוח לימודים מפרסמת רשימת שיעורים לאפליקציית בית־ספר אחרת. תיעוד ContentProvider ו־FileProvider מציגים שני מסלולים: הראשון לחוזה נתונים כללי, השני לשיתוף קבצים זמני כמו במעבדת המצלמה.

משימת בחירה: תכננו אפליקציית לוח ציונים ואפליקציית הורה נפרדת. איזו שאילתת content:// תרצו לאפשר, איזו הרשאה תגן עליה, ואיך תמנעו חשיפת ציוני תלמידים אחרים? רק אם יש צרכן חיצוני אמיתי ובדיקת הרשאות בין שתי אפליקציות, מימוש ContentProvider מוצדק. למשחק הכדור כאן אין צרכן כזה.

הקוד המשלים במלואו

הקטעים הגלויים בשיעור ממקדים את הרעיון; הקבצים הבאים משלימים את כל הקוד הדרוש, עם תיעוד והערות. קראו את השינוי יחד עם ההסבר שמעליו. הם חלק מן השיעור ואינם דורשים פתיחת ענף דוגמה או אתר שפורסם.

MainActivity.java

פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/26/MainActivity.java.md ביחס לשיעור.

הקוד המלא והשינויים עבור MainActivity.java

ב־MainActivity.java החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.

 import com.example.topics.databinding.ActivityMainBinding;
 
 public class MainActivity extends AppCompatActivity {
-
     private ActivityMainBinding binding;
 
+    /**
+     * Creates the current Activity View tree and connects the screen's actions.
+     *
+     * @param savedInstanceState prior small UI snapshot, or null for a fresh launch
+     */
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
 ⁞
         setContentView(binding.getRoot());
         ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
             Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
-            v.setPadding(bars.left, bars.top, bars.right, bars.bottom);            return insets;
+            v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
+            return insets;
         });
     }
+
+    /**
+     * Allows rendering only while the Activity is active.
+     */
+    @Override
+    protected void onResume() {
+        super.onResume();
+        binding.ball.resume();
+    }
+
+    /**
+     * Stops and joins rendering before the Activity becomes inactive.
+     */
+    @Override
+    protected void onPause() {
+        binding.ball.pause();
+        super.onPause();
+    }
 }

BouncingBallView.java

פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/26/BouncingBallView.java.md ביחס לשיעור.

הקוד המלא והשינויים עבור BouncingBallView.java

צרו BouncingBallView.java במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.

package com.example.topics;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import java.util.concurrent.atomic.AtomicInteger;

/** A tiny game surface with one render thread while the surface is visible. */
public class BouncingBallView extends SurfaceView implements SurfaceHolder.Callback {
    private final Object positionLock = new Object();
    private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final SurfaceHolder holder;
    private final AtomicInteger frames = new AtomicInteger();
    private final float radius;
    private float x = -1;
    private float y = -1;
    private float vx = 360;
    private float vy = 280;
    private boolean resumed;
    private boolean surfaceReady;
    private volatile boolean running;
    private Thread renderer;

    /**
     * Connects XML inflation to a Surface holder and sets a density-scaled radius.
     *
     * @param context View context
     * @param attrs XML attributes used by the inflater
     */
    public BouncingBallView(Context context, AttributeSet attrs) {
        super(context, attrs);
        holder = getHolder();
        radius = 28 * getResources().getDisplayMetrics().density;
        holder.addCallback(this);
        setContentDescription(getResources().getString(R.string.ball_description));
    }

    /**
     * Allows the loop to start if the Surface also exists; called on main.
     */
    public void resume() {
        resumed = true;
        startIfReady();
    }

    /**
     * Stops and joins the loop before returning; called on main.
     */
    public void pause() {
        resumed = false;
        stopRenderer();
    }

    /**
     * Marks the Surface ready and starts only if the Activity is also resumed.
     *
     * @param holder holder whose Surface became available
     */
    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        surfaceReady = true;
        startIfReady();
    }

    /**
     * Receives size changes; the loop reads the current Canvas size each frame.
     *
     * @param holder changed Surface holder
     * @param format pixel format
     * @param width current width in pixels
     * @param height current height in pixels
     */
    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { }

    /**
     * Stops the worker before Android releases this Surface.
     *
     * @param holder holder whose Surface is being removed
     */
    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        surfaceReady = false;
        stopRenderer();
    }

    /**
     * Creates at most one renderer when both lifecycle conditions permit drawing.
     */
    private void startIfReady() {
        if (!resumed || !surfaceReady || renderer != null) return;
        running = true;
        renderer = new Thread(this::renderLoop, "ball-renderer");
        renderer.start();
    }

    /**
     * Signals the worker, wakes its sleep, and joins it before clearing its reference.
     * The main thread must never hold a lock needed by the worker while joining.
     */
    private void stopRenderer() {
        // Do not hold positionLock while joining: the renderer may need that lock to exit.
        running = false;
        Thread thread = renderer;
        if (thread == null) return;
        thread.interrupt();
        boolean interrupted = false;
        while (thread.isAlive()) {
            try {
                thread.join(); // surfaceDestroyed must wait until the worker stops touching the Surface.
            } catch (InterruptedException retry) {
                interrupted = true;
            }
        }
        if (interrupted) Thread.currentThread().interrupt();
        renderer = null;
    }

    /**
     * Advances positions by elapsed seconds and redraws the full buffer on the renderer thread.
     * Every nonnull locked Canvas is returned in finally, even if drawing fails.
     */
    private void renderLoop() {
        long previous = System.nanoTime();
        while (running) {
            long start = System.nanoTime();
            // Limit a delayed frame so a pause does not produce one huge physics jump.
            float dt = Math.min(0.05f, (start - previous) / 1_000_000_000f);
            previous = start;
            Canvas canvas = null;
            try {
                canvas = holder.lockCanvas();
                if (canvas != null) {
                    canvas.drawColor(Color.rgb(15, 25, 45)); // Redraw the full buffer each frame.
                    synchronized (positionLock) {
                        if (x < 0 || y < 0) {
                            x = canvas.getWidth() / 2f;
                            y = canvas.getHeight() / 2f;
                        }
                        x += vx * dt;
                        y += vy * dt;
                        if (x < radius || x > canvas.getWidth() - radius) {
                            vx = -vx;
                            x = Math.max(radius, Math.min(x, canvas.getWidth() - radius));
                        }
                        if (y < radius || y > canvas.getHeight() - radius) {
                            vy = -vy;
                            y = Math.max(radius, Math.min(y, canvas.getHeight() - radius));
                        }
                        paint.setColor(Color.rgb(64, 210, 244));
                        canvas.drawCircle(x, y, radius, paint);
                    }
                    // UI/test readers can observe this counter without sharing the position lock.
                    frames.incrementAndGet();
                }
            } finally {
                if (canvas != null) holder.unlockCanvasAndPost(canvas);
            }
            long elapsedMs = (System.nanoTime() - start) / 1_000_000;
            try {
                Thread.sleep(Math.max(1, 16 - elapsedMs));
            } catch (InterruptedException stopped) {
                if (!running) return;
            }
        }
    }

    /**
     * Moves the ball under a touch while sharing position access safely with the renderer.
     *
     * @param event current touch event on main
     * @return true because this View consumes the gesture
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
            synchronized (positionLock) {
                x = event.getX();
                y = event.getY();
            }
            return true;
        }
        if (event.getActionMasked() == MotionEvent.ACTION_UP) performClick();
        return true;
    }

    /**
     * Reports the completed click through the View accessibility event machinery.
     *
     * @return true because this View handles the click
     */
    @Override
    public boolean performClick() {
        super.performClick();
        return true;
    }

    /**
     * Returns a thread-safe count of frames whose drawing code ran.
     *
     * @return total rendered frame count
     */
    public int getFrameCount() { return frames.get(); }
}

SurfaceLoopTest.java

פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/26/SurfaceLoopTest.java.md ביחס לשיעור.

הקוד המלא והשינויים עבור SurfaceLoopTest.java

צרו SurfaceLoopTest.java במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.

package com.example.topics;

import androidx.lifecycle.Lifecycle;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

@RunWith(AndroidJUnit4.class)
public class SurfaceLoopTest {
    /**
     * Checks that frames advance while resumed, stop after pause, and advance on resume.
     */
    @Test
    public void framesStopWhenActivityPausesAndResumeWhenItReturns() throws Exception {
        try (ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class)) {
            AtomicInteger count = new AtomicInteger();
            Thread.sleep(300);
            scenario.onActivity(activity -> count.set(((BouncingBallView) activity.findViewById(R.id.ball)).getFrameCount()));
            assertTrue(count.get() > 0);
            scenario.moveToState(Lifecycle.State.STARTED);
            AtomicInteger paused = new AtomicInteger();
            scenario.onActivity(activity -> paused.set(((BouncingBallView) activity.findViewById(R.id.ball)).getFrameCount()));
            Thread.sleep(200);
            scenario.onActivity(activity -> count.set(((BouncingBallView) activity.findViewById(R.id.ball)).getFrameCount()));
            assertEquals(paused.get(), count.get());
            scenario.moveToState(Lifecycle.State.RESUMED);
            Thread.sleep(250);
            scenario.onActivity(activity -> count.set(((BouncingBallView) activity.findViewById(R.id.ball)).getFrameCount()));
            assertTrue(count.get() > paused.get());
        }
    }
}

activity_main.xml

פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/26/activity_main.xml.md ביחס לשיעור.

הקוד המלא והשינויים עבור activity_main.xml

ב־activity_main.xml החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.

     android:layout_width="match_parent"
     android:layout_height="match_parent"
     tools:context=".MainActivity">
-
     <TextView
-        android:layout_width="wrap_content"
+        android:id="@+id/instruction"
+        android:layout_width="0dp"
         android:layout_height="wrap_content"
-        android:text="Hello World!"
+        android:padding="16dp"
+        android:text="@string/ball_instruction"
+        android:textSize="18sp"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent" />
+    <com.example.topics.BouncingBallView
+        android:id="@+id/ball"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:layout_constraintTop_toBottomOf="@id/instruction"
         app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
-
+        app:layout_constraintEnd_toEndOf="parent" />
 </androidx.constraintlayout.widget.ConstraintLayout>

strings.xml

פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/26/strings.xml.md ביחס לשיעור.

הקוד המלא והשינויים עבור strings.xml

ב־strings.xml החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.

 <resources>
     <string name="app_name">topics</string>
+    <string name="ball_instruction">Tap the surface to move the bouncing ball. Leave and return to observe the render loop stopping and restarting.</string>
+    <string name="ball_description">Animated bouncing ball game surface. Tap to move the ball.</string>
 </resources>

לחיצה מסתיימת בקריאה ל־performClick() ב־ACTION_UP, כדי שמנגנון הנגישות של View יקבל אירוע click. זה אינו הופך את המשחק לנגיש במלואו; משחק אמיתי צריך גם דרך חלופית להזיז את הכדור בלי מחוות מדויקות.