Android topics — 01: מה נשמר אחרי סיבוב?


שדה, Bundle, שמירה מתמשכת ומחזור חיי ה־View של Fragment

English version

מפת המעבדות

קוד המקור של המעבדה ב־GitHub — Android-Lifecycle. אפשר לפתוח את הפרויקט ב־Android Studio, להריץ את הניסויים ולהשוות את הקוד להסברים כאן.

בסוף המעבדה יופיעו שלושה מונים ב־Activity ומונה נוסף ב־Fragment. נעלה את כולם, נסובב את המכשיר, נהרוג את האפליקציה ונחזור אליה. נוכל לנבא איזה ערך ישרוד כל פעולה. בלחיצה נפרדת נהרוס רק את ה־View של ה־Fragment, נחזור עם Back ונראה שאותו מופע Fragment נשאר.

עצרו ונבאו

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

בדיקת ההבנה

השדה הרגיל מתחיל ב־0 ב־Activity החדשה. מונה ה־Bundle משוחזר ל־1 עבור יצירה מחדש. מונה ההעדפות נקרא מן האחסון ונשאר 1. סדר השחזור לפני render הוא שמביא את המצב הנכון אל ה־Views החדשים.

נקודת התחלה ושאלת חיזוי

התחילו מפרויקט Empty Views Activity ב־Java וב־XML, עם החבילה com.example.topics. תחילה הפעילו והכירו את View Binding בסעיף הבא; יתר המעבדה נשענת על התבנית לאחר ההסבה. במאגר Android-Lifecycle נמצא הפרויקט המוכן בענף main; ב־Git Log אפשר להשוות את השינוי שמממש את המעבדה לבסיס התבנית עם View Binding. ב־Android Studio אפשר למצוא את הקבצים דרך app > kotlin+java > com.example.topics,‏ app > res > layout ו־app > res > values.

הסבת הפרויקט ל־View Binding

View Binding יוצר מחלקה עם הפניות ישירות ל־Views שבקובץ ה־XML, כך שלא צריך לחפש כל רכיב שוב באמצעות findViewById. כך הקוד קצר יותר, ושגיאות של מזהה או המרה בין טיפוסים מתגלות מוקדם יותר.

שלב 1 — הפעלת View Binding ב־Gradle

פתחו את build.gradle.kts (Module :app) והוסיפו את buildFeatures:

 android {
     namespace = "com.example.topics"
     compileSdk {
         version = release(36)
     }

+    buildFeatures {
+        viewBinding = true
+    }
+
     defaultConfig {
         ...
     }
 }

לאחר העריכה סנכרנו את ה-Gradle alt.

שלב 2 — המרת MainActivity לשימוש ב־Binding

עִרְכוּ את MainActivity.java:

לפני

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    EdgeToEdge.enable(this);

    setContentView(R.layout.activity_main);
    ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
        Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
        v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
        return insets;
    });
}

אחרי

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    EdgeToEdge.enable(this);
    binding = ActivityMainBinding.inflate(getLayoutInflater());
    setContentView(binding.getRoot());
    ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
        Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
        v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
        return insets;
    });
}

השדה binding מופיע באדום. בצעו right-click > ShowContextActions ובחרו Create Field: תפריט הפעולות של Android Studio עם האפשרות Create field 'binding' in 'MainActivity' זה יוסיף את השדה וגם יוסיף את ה-import שחסר לנו.

הסבר קצר: binding.getRoot() הוא ה־View הראשי של הפריסה. ב־binding.main ניגשים ל־View עם android:id="@+id/main"; לכן אין עוד צורך ב־findViewById. קוד ה־insets והריווח נשאר ללא שינוי.

לפני כתיבת הקוד, רשמו ניחוש לכל שורה:

פעולה שדה רגיל ב־Activity ערך ב־Bundle ערך ב־SharedPreferences
סיבוב מסך ? ? ?
מעבר לרקע עם Don't keep activities וחזרה ? ? ?
Force stop ופתיחה מחדש ? ? ?

Bundle של onSaveInstanceState מיועד לשחזור מצב ממשק כשהמערכת יוצרת מחדש רכיב. הוא אינו מסד נתונים ואינו התחייבות לשחזור אחרי Force stop או פתיחה חדשה של האפליקציה. SharedPreferences הוא מקור נתונים מקומי שנשמר גם אחרי סגירת התהליך. שדה Java רגיל חי רק כל עוד המופע שלו חי.

מפת מחשבה: ערך, בעלים וזמן חיים

כשאומרים שהערך “נשמר”, צריך להשלים: איפה הוא נמצא, ומי יוכל להחזיר אותו אחרי שהבעלים שלו ייעלם? לחיצה מעלה שלושה מספרים זהים, אבל הם חיים בשלושה מקומות שונים. memoryCount שייך למופע Java הנוכחי; savedCount מועתק למעטפת שחזור ש־Android עשויה למסור למופע הבא; persistentCount נקרא מקובץ פרטי. הטקסט שעל המסך הוא תצוגה שלהם, ולא המקום שבו כדאי לשמור אותם.

sequenceDiagram
    participant Old as Activity A
    participant OS as Android
    participant New as Activity B
    participant Disk as Preferences file
    Old->>Disk: apply persistent count
    Old->>OS: save small UI state in Bundle
    Note over Old: rotation destroys A
    OS->>New: onCreate(savedInstanceState)
    New->>Disk: read persistent count
    Note over New: ordinary fields start anew
    New->>New: restore state, then render

עקבו בתרשים אחר מספר אחד, למשל 2. המספר אינו עובר מעצמו בין שדות של שני מופעים: הקוד קורא אותו מן ה־Bundle או מן הקובץ. אחרי Force stop ופתיחה חדשה נשאר מסלול הקובץ, אך אין להניח שיימסר Bundle של המשימה הישנה. אלה שני ניסויים שונים, ולא שני שמות לאותה פעולה.

ל־Fragment יש בנוסף שני זמני חיים: האובייקט שמחזיק count, ועץ ה־Views שמציג אותו. detach והחזרה מה־back stack יכולים להרוס וליצור את העץ בלי להחליף את האובייקט. ה־binding הישן מחזיק הפניות לעץ שנהרס, ולכן מנקים אותו. אין מאפסים את count ב־onCreateView: יצירת תצוגה אינה בקשה למחוק את הנתון. מחזור חיי Fragment בתיעוד Android מגדיר את מחזור חיי התצוגה בנפרד.

בקריאת callback שאלו שתי שאלות: מי קורא לי, ואיזה משאב תקף עכשיו? Android קוראת ל־onCreateView; בו מותר ליצור binding. Android קוראת ל־onDestroyView; אחריו אסור להשתמש באותו binding. renderCount היא מתודה שלנו, ולכן האחריות לקרוא לה רק כשיש View היא שלנו.

בונים את הניסוי

קובץ חדש מוצג במלואו. בקובץ קיים, קטע diff מציג את האזור המשתנה בלבד: מסירים את שורות -, מוסיפים את שורות + ומשאירים שורות הקשר. אין למחוק קוד תבנית שלא מופיע בקטע.

1. מחרוזות התצוגה

ב־app > res > values > strings.xml הוסיפו את המחרוזות. המספרים הם placeholders של Android; התצוגה תספק להם ערך באמצעות getString.

 <resources>
     <string name="app_name">topics</string>
+    <string name="lifecycle_intro">Tap Increment, rotate the device, then force stop and relaunch. Which values survive?</string>
+    <string name="memory_value">Activity field: %1$d</string>
+    <string name="saved_value">Saved Bundle: %1$d</string>
+    <string name="persistent_value">SharedPreferences: %1$d</string>
+    <string name="increment">Increment all three</string>
+    <string name="reset">Reset counters</string>
+    <string name="fragment_intro">Fragment view lifecycle (watch LifecycleDemo in Logcat)</string>
+    <string name="fragment_value">Fragment saved count: %1$d</string>
+    <string name="fragment_increment">Increment Fragment</string>
+    <string name="detach_view">Destroy Fragment View (press Back to restore)</string>
 </resources>

2. ה־View של ה־Fragment

צרו ב־app > res > layout את fragment_lifecycle.xml. הכפתור האחרון מנתק את ה־Fragment זמנית; Back מחבר אותו שוב. כך אפשר לראות מחזור View חדש בלי ליצור מופע Fragment חדש.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/fragment_intro" />

    <TextView
        android:id="@+id/fragment_value"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <Button
        android:id="@+id/fragment_increment"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/fragment_increment" />

    <Button
        android:id="@+id/detach_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/detach_view" />
</LinearLayout>

3. מחלקת ה־Fragment

צרו ב־app > kotlin+java > com.example.topics מחלקת Java בשם LifecycleFragment. המחלקה שומרת count בשדה שלה ומעתיקה אותו גם ל־Bundle לפני שחזור מערכת. binding מתייחס ל־View הנוכחי בלבד: אנחנו יוצרים אותו ב־onCreateView ומנקים אותו ב־onDestroyView, כדי שלא להחזיק View שכבר נהרס.

package com.example.topics;

import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;

import com.example.topics.databinding.FragmentLifecycleBinding;

/** Demonstrates that a Fragment and its View have separate lifetimes. */
public final class LifecycleFragment extends Fragment {
    private static final String TAG = "LifecycleDemo";
    private static final String SAVED_COUNT = "fragment_count";

    private FragmentLifecycleBinding binding;
    private int count;

    /**
     * Creates the current View tree; the Fragment object may already exist.
     *
     * @param inflater creates Views using the host theme
     * @param container parent used for layout parameters; do not attach yet
     * @param savedInstanceState prior UI state, or null on a fresh creation
     * @return the root of this new View tree
     */
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
                             @Nullable Bundle savedInstanceState) {
        Log.d(TAG, "Fragment " + System.identityHashCode(this) + " onCreateView");
        // The manager attaches the root; attaching here would attach it twice.
        binding = FragmentLifecycleBinding.inflate(inflater, container, false);
        return binding.getRoot();
    }

    /**
     * Restores the counter and connects actions after the current binding exists.
     * A detach/back cycle retains the field when no saved Bundle is supplied.
     *
     * @param view the newly created root
     * @param savedInstanceState saved counter when Android restores this Fragment
     */
    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        Log.d(TAG, "Fragment " + System.identityHashCode(this) + " onViewCreated");
        if (savedInstanceState != null) {
            count = savedInstanceState.getInt(SAVED_COUNT);
        }
        binding.fragmentIncrement.setOnClickListener(v -> {
            count++;
            renderCount();
        });
        binding.detachView.setOnClickListener(v -> getParentFragmentManager()
                .beginTransaction()
                .detach(this)
                .addToBackStack("detached_view")
                .commit());
        renderCount();
    }

    /**
     * Copies this component's small UI counter into Android's restoration Bundle.
     * This is a restoration snapshot, not durable storage for a fresh app launch.
     *
     * @param outState destination delivered to a later restored instance
     */
    @Override
    public void onSaveInstanceState(@NonNull Bundle outState) {
        outState.putInt(SAVED_COUNT, count);
        super.onSaveInstanceState(outState);
    }

    /**
     * Releases references to the old View tree while the Fragment may stay alive.
     */
    @Override
    public void onDestroyView() {
        Log.d(TAG, "Fragment " + System.identityHashCode(this)
                + " onDestroyView; clearing binding");
        // The Fragment can outlive this View; release the obsolete references.
        binding = null;
        super.onDestroyView();
    }

    /**
     * Displays the Fragment counter without changing it.
     * Call only between View creation and View destruction.
     */
    private void renderCount() {
        binding.fragmentValue.setText(getString(R.string.fragment_value, count));
    }
}

המספר מ־identityHashCode(this) מזהה את מופע ה־Fragment באותו תהליך לצורך התצפית בלבד; אין לשמור אותו כמזהה עסקי. אחרי detach מופיעה ב־Logcat שורת onDestroyView, ואחרי Back מופיעה onCreateView עם אותו מספר. הספירה נשארת בשדה count, אף שה־View ובניית ה־binding התחלפו.

4. מסך ה־Activity

ב־app > res > layout > activity_main.xml השאירו את ConstraintLayout של התבנית ואת המזהה main. החליפו רק את TextView של Hello World בתוכן הבא. ה־ScrollView משאיר את הכפתורים נגישים גם במסך קטן או במצב landscape. FragmentContainerView יוצר את LifecycleFragment מתוך android:name.

-    <TextView
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="Hello World!"
+    <ScrollView
+        android:layout_width="0dp"
+        android:layout_height="0dp"
         app:layout_constraintBottom_toBottomOf="parent"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
+        app:layout_constraintTop_toTopOf="parent">

+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical"
+            android:padding="24dp">
+
+            <TextView
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="@string/lifecycle_intro"
+                android:textSize="18sp" />
+
+            <TextView
+                android:id="@+id/memory_value"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="24dp"
+                tools:text="Activity field: 0" />
+
+            <TextView
+                android:id="@+id/saved_value"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="8dp"
+                tools:text="Saved Bundle: 0" />
+
+            <TextView
+                android:id="@+id/persistent_value"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="8dp"
+                tools:text="SharedPreferences: 0" />
+
+            <Button
+                android:id="@+id/increment"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="16dp"
+                android:text="@string/increment" />
+
+            <Button
+                android:id="@+id/reset"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="@string/reset" />
+
+            <androidx.fragment.app.FragmentContainerView
+                android:id="@+id/lifecycle_fragment"
+                android:name="com.example.topics.LifecycleFragment"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="32dp" />
+        </LinearLayout>
+    </ScrollView>

ל־tools:text יש תפקיד בתצוגת העיצוב של Android Studio בלבד; ערכי הריצה יגיעו מ־renderCounts. android:name הוא שם המחלקה המלא של ה־Fragment, ולכן טעות בו תגרום לקריסה בזמן ניפוח המסך.

5. שלוש דרכי השמירה ב־MainActivity

ב־app > kotlin+java > com.example.topics > MainActivity הוסיפו את הייבוא, הקבועים והשדות. memoryCount לא משוחזר בכוונה; הוא קבוצת הביקורת בניסוי. savedCount ישוחזר מתוך savedInstanceState, ואילו persistentCount ייטען מהאחסון המקומי.

 package com.example.topics;

+import android.content.SharedPreferences;
 import android.os.Bundle;
 ⁞
 public class MainActivity extends AppCompatActivity {
+    private static final String PREFS = "lifecycle_demo";
+    private static final String SAVED_COUNT = "saved_count";
+    private static final String PERSISTENT_COUNT = "persistent_count";
+
     private ActivityMainBinding binding;
+    private int memoryCount;
+    private int savedCount;
+    private int persistentCount;
+    private SharedPreferences preferences;

אחרי ViewCompat.setOnApplyWindowInsetsListener(...) הקיים, הוסיפו בתוך onCreate את הקריאה, המאזינים והציור הראשוני. אם בשורת ה־listener בתבנית שלכם return insets; מחובר לשורת setPadding, הפרידו אותם לשתי שורות; ההתנהגות נשארת זהה.

        preferences = getSharedPreferences(PREFS, MODE_PRIVATE);
        persistentCount = preferences.getInt(PERSISTENT_COUNT, 0);
        if (savedInstanceState != null) {
            savedCount = savedInstanceState.getInt(SAVED_COUNT);
        }
        binding.increment.setOnClickListener(v -> {
            memoryCount++;
            savedCount++;
            persistentCount++;
            // Persist the model value, never the formatted TextView text.
            preferences.edit().putInt(PERSISTENT_COUNT, persistentCount).apply();
            renderCounts();
        });
        binding.reset.setOnClickListener(v -> {
            memoryCount = 0;
            savedCount = 0;
            persistentCount = 0;
            preferences.edit().remove(PERSISTENT_COUNT).apply();
            renderCounts();
        });
        renderCounts();

apply() מעדכן את הערך בזיכרון מיד ומבצע את כתיבת הקובץ בהמשך. לחצו על הכפתור, המתינו שהערכים יוצגו ורק אז בצעו Force stop בניסוי. בסוף המחלקה הוסיפו את השמירה הזמנית ואת ציור הערכים. הקריאה ל־super.onSaveInstanceState משאירה גם ל־Android לשמור את מצב ה־Views שלו.

    /**
     * Copies this component's small UI counter into Android's restoration Bundle.
     * This is a restoration snapshot, not durable storage for a fresh app launch.
     *
     * @param outState destination delivered to a later restored instance
     */
    @Override
    protected void onSaveInstanceState(Bundle outState) {
        outState.putInt(SAVED_COUNT, savedCount);
        super.onSaveInstanceState(outState);
    }

    /**
     * Displays the three counters from their owning state.
     * Rendering does not increment, restore, or write any counter.
     */
    private void renderCounts() {
        binding.memoryValue.setText(getString(R.string.memory_value, memoryCount));
        binding.savedValue.setText(getString(R.string.saved_value, savedCount));
        binding.persistentValue.setText(getString(R.string.persistent_value, persistentCount));
    }

בנו והפעילו את האפליקציה. אם binding.increment או FragmentLifecycleBinding אינם מזוהים, ודאו שקובצי ה־XML נשמרו, שהמזהים תואמים וש־Gradle Sync/Build הסתיימו.

מעל @Override של onCreate הקיימת הוסיפו את ה־Javadoc הבא. אין להחליף את גוף המתודה או למחוק את טיפול ה־insets של התבנית:

    /**
     * Creates the current Activity View tree and connects the screen's actions.
     *
     * @param savedInstanceState prior small UI snapshot, or null for a fresh launch
     */

בודקים את התחזיות

  1. לחצו פעמיים על Increment all three ופעם על Increment Fragment. כל מוני ה־Activity אמורים להציג 2, ומונה ה־Fragment יציג 1.
  2. סובבו את המכשיר. שדה ה־Activity יחזור ל־0; ה־Bundle וה־SharedPreferences יישארו 2; מונה ה־Fragment יישאר 1 בזכות ה־Bundle שלו.
  3. לחצו על Destroy Fragment View, ואז על Back של המכשיר. סננו את Logcat לפי LifecycleDemo: תראו onDestroyView ואחריו onCreateView עם אותו מספר מופע. מונה ה־Fragment עדיין 1. זו ההבחנה בין חיי ה־Fragment לחיי ה־View שלו.
  4. במסך אפשרויות המפתח הפעילו Don’t keep activities. עברו למסך הבית וחזרו לאפליקציה דרך המסך האחרון. בדקו שוב את שלושת המונים. בטלו את האפשרות בסיום; תוצאת החזרה עשויה להשתנות אם המערכת פתחה משימה חדשה במקום לשחזר את הקודמת.
  5. בצעו Force stop דרך הגדרות האפליקציה, ואז פתחו אותה מן הסמל. רק ערך ה־SharedPreferences אמור להישאר 2. מחקו נתוני אפליקציה רק אם רוצים להתחיל ניסוי חדש לחלוטין.
תצפית נמדדת בענף הדוגמה שדה Activity Bundle SharedPreferences מונה Fragment
אחרי סיבוב, כאשר ערכי ה־Activity התחילו ב־1 0 1 1 1
אחרי Force stop ופתיחה מחדש 0 0 1 0

סיבוב מסך יוצר Activity חדש, ולכן שדה רגיל מתחיל מחדש. savedInstanceState עשוי לשחזר Activity אחרי הריגת תהליך כאשר Android משחזר את אותה משימה; אין להסתמך עליו כעל אחסון מתמשך. אחרי Force stop ופתיחה חדשה אין מסלול שחזור כזה. מקור אמת ארוך חיים צריך להיכתב לאחסון מתאים, ולא להישאר רק ב־View או בשדה של Fragment.

בדיקת הבנה

  1. מה יקרה למונה ה־Fragment אם נמחק את outState.putInt ונסובב את המסך? מדוע Back אחרי detach עדיין יכול לשמור אותו?
  2. למה binding = null שייך ל־onDestroyView ולא ל־onDestroy?
  3. אם היינו שומרים רק את ערך savedCount ב־SharedPreferences, איזה סוג מצב היינו הופכים למתמשך? האם זה מתאים תמיד לטיוטת מסך זמנית?

דוגמה משלימה: מימוש טיפוסי של Fragments

המאגר 3strategy/fragments ב־GitHub מציג מימוש טיפוסי ופשוט של Fragments ב־Java וב־XML:‏ Activity אחד, מיכל תצוגה ושלושה מסכים — Home, Dashboard ו־Notifications — שנבחרים באמצעות סרגל ניווט תחתון. כדאי להשוות אותו למעבדת ה־Lifecycle: כאן מתנסים בהרס וביצירה מחדש של View, ובדוגמה המשלימה מחליפים את ה־Fragment המוצג.

מפת callbacks מפורטת: מי קורא לכל מתודה ומתי?

הרחבה על שליטה ב־Fragment

הרחבה על שליטה ב־Fragment: יצירה, החלפה, Back ומחזור חיי התצוגה

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

1. איך Back מחבר מחדש את ה־Fragment אם אין בקוד קריאה ל־attach()?

Back מבטל את העסקה שנרשמה ב־FragmentManager. ב־onViewCreated של LifecycleFragment.java מופיע המאזין:

binding.detachView.setOnClickListener(v -> getParentFragmentManager()
        .beginTransaction()
        .detach(this)
        .addToBackStack("detached_view")
        .commit());

getParentFragmentManager() מחזיר את המנהל שאחראי ל־Fragment הזה. beginTransaction() מתחיל עסקה — קבוצת פעולות על Fragments. detach(this) מבקש לנתק את התצוגה, ו־addToBackStack(...) רושם את העסקה במחסנית החזרה. commit() מתזמן את ביצועה; הוא אינו מבטיח שהיא כבר בוצעה בשורה הבאה. המחרוזת "detached_view" היא שם לרשומה במחסנית, ולא שם של מתודה או callback.

בלחיצה על Back של המכשיר, מנגנון החזרה של AndroidX מוציא את העסקה העליונה מהמחסנית ומבצע את הפעולות ההפוכות שלה. הפעולה ההפוכה ל־detach היא חיבור מחדש באמצעות attach. המנהל עושה זאת פנימית, ולכן בדוגמה הזאת אין צורך במאזין Back משלנו או בקריאת attach() מפורשת. ראו ניהול מחסנית החזרה ועסקאות Fragment.

עקבו אחר הרצף בניסוי:

  1. ה־Fragment וה־View שלו מוצגים על המסך.
  2. לחיצה על Destroy Fragment View מבצעת את עסקת הניתוק. ה־View נהרס, אבל אובייקט ה־Fragment נשאר בניהול FragmentManager.
  3. onDestroyView() מנקה את ה־binding ומשחרר את ההפניות של ה־Fragment לעץ התצוגה הישן.
  4. Back מוציא את העסקה מהמחסנית והופך את הניתוק.
  5. onCreateView() יוצר עץ תצוגה ו־binding חדשים. onViewCreated() מחבר מחדש את המאזינים וקורא ל־renderCount().

הניקוי ב־onDestroyView() הוא:

binding = null;
super.onDestroyView();

ויצירת התצוגה החדשה ב־onCreateView() היא:

binding = FragmentLifecycleBinding.inflate(inflater, container, false);
return binding.getRoot();

במחזור פשוט של ניתוק ו־Back, בלי יצירה מחדש של ה־Activity או הריגת התהליך, אותו אובייקט Fragment נשאר בחיים. לכן גם השדה count נשאר. binding = null אינו מאפס אותו; renderCount() מציג אותו דרך ה־binding החדש. השוו את מספרי המופע ב־Logcat לפני הניתוק ואחרי החזרה כדי לבדוק זאת. אם Android יוצר מחדש את הרכיב, ההסבר כבר אינו הישרדות האובייקט הישן: הפרויקט שומר את count ב־onSaveInstanceState() ומשחזר אותו ב־onViewCreated() כשנמסר Bundle שמור.

בלי addToBackStack(...),‏ Back לא יבטל אוטומטית את הניתוק; לחיבור מפורש תידרש עסקה אחרת עם attach(fragment). הפעולה detach() גם אינה ה־callback בשם onDetach(): הרס ה־View אינו הרס אובייקט ה־Fragment או ניתוק שלו מה־Activity. ראו חיבור וניתוק Fragments.

2. האם ההצהרה ב־XML מספיקה להצגת ה־Fragment, או שגם MainActivity מוסיף אותו?

ה־XML קובע איזה Fragment ליצור; ה־Activity טוען ומציג את הפריסה. ב־MainActivity.java של Lifecycle אין עסקת add() מפורשת. הבחירה במחלקה נמצאת ב־activity_main.xml:

<androidx.fragment.app.FragmentContainerView
    android:id="@+id/lifecycle_fragment"
    android:name="com.example.topics.LifecycleFragment"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="32dp" />

android:name מציין איזו מחלקת Fragment ליצור אוטומטית. FragmentContainerView בלי המאפיין הזה מספק מיכל, אבל אינו בוחר Fragment שייכנס אליו. android:id מזהה את המיכל. בזמן טעינת הפריסה AndroidX מוסיף את ה־Fragment המוצהר, או משתמש ב־Fragment ששוחזר על ידי המנהל, לפי המצב. ראו הוספת Fragment באמצעות XML.

עדיין דרוש Activity שתומך ב־Fragments וטוען את הפריסה. כאן MainActivity יורש מ־AppCompatActivity, שתומך ב־AndroidX Fragments, וב־onCreate() מתבצעות הקריאות:

super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());

ActivityMainBinding נוצר מתוך activity_main.xml בזכות הפעלת View Binding ב־app/build.gradle.kts. inflate(...) בונה את הפריסה, כולל המיכל; setContentView(...) מציב את השורש שלה כמסך ה־Activity. מחלקת האב מספקת את תשתית ה־Fragments ואת הטיפול בשחזור.

ל־Fragment עצמו יש פריסה נפרדת: LifecycleFragment.onCreateView() טוען את fragment_lifecycle.xml באמצעות FragmentLifecycleBinding ומחזיר את השורש. הארגומנט false משאיר את חיבור השורש למיכל ל־FragmentManager.

לכן, במסגרת ה־Activity הקיים, הצהרת ה־XML מספיקה לבחירת ה־Fragment ולהוספתו האוטומטית. קוד ה־Java נחוץ לאירוח ולטעינת המסך, אבל אין להוסיף כאן עסקת add() או attach() נוספת. מאזיני המונים, EdgeToEdge וטיפול ה־insets אינם בוחרים או מוסיפים Fragment.

אם מסירים את android:name, צריך לבחור ולהוסיף את ה־Fragment בקוד. בדרך כלל עושים זאת רק ביצירה ראשונה (savedInstanceState == null), כדי לאפשר למנהל לשחזר אותו ביצירה מחדש. זו חלופה להצהרת ה־XML, ולא שלב נוסף במימוש הנוכחי.

3. מי קורא ל־onSaveInstanceState(), ואיך ה־Bundle מגיע למופע החדש?

Android מבקש תמונת מצב, הקוד שלנו ממלא אותה, והמערכת מוסרת את המצב השמור בזמן שחזור. מאזיני הכפתורים אינם קוראים ל־onSaveInstanceState(). Android קורא ל־callback של ה־Activity כחלק ממחזור החיים ומספק את outState; אנחנו מוסיפים אליו את הערכים שנרצה לשחזר.

ב־MainActivity של המעבדה כבר כתבנו:

@Override
protected void onSaveInstanceState(Bundle outState) {
    outState.putInt(SAVED_COUNT, savedCount);
    super.onSaveInstanceState(outState);
}

SAVED_COUNT הוא המפתח "saved_count". אם בזמן הקריאה savedCount שווה 2,‏ putInt(...) מכניס 2 ל־Bundle תחת המפתח הזה. הוא אינו שומר את אובייקט ה־Activity, את ה־binding או את כל השדות אוטומטית. הקריאה ל־super.onSaveInstanceState(outState) מאפשרת למימוש שבירושה להוסיף מצב של Android/AndroidX, כולל מצב תצוגות נתמכות ומצב מנהל ה־Fragments; היא אינה קוראת שוב ל־override שלנו.

מתי נשמרת תמונת המצב בסיבוב?

בפרויקט המעבדה minSdk הוא 31 ו־targetSdk הוא 37. בסיבוב רגיל שיוצר Activity מחדש, סדר קריאות ה־Activity הוא:

Old MainActivity:
    onPause()
    onStop()
    onSaveInstanceState(outState)   <-- copy savedCount into the snapshot
    onDestroy()

New MainActivity:
    onCreate(savedInstanceState)   <-- read savedCount from the snapshot
    onStart()
    onRestoreInstanceState(...)    <-- inherited restoration; no override here
    onResume()

באפליקציות המכוונות ל־API 28 ומעלה, כאשר onSaveInstanceState() נקראת, היא מתבצעת אחרי onStop(); כללי הסדר הישנים שונים. התרשים מתאר סיבוב, ולא מבטיח שכל עצירה או הריסה תפעיל שמירה. ראו תיעוד callback השמירה של Activity.

ב־onCreate() של ה־Activity החדש קוראים את הערך:

if (savedInstanceState != null) {
    savedCount = savedInstanceState.getInt(SAVED_COUNT);
}

outState הוא שם הפרמטר של תמונת המצב היוצאת שממלאים; savedInstanceState הוא שם הפרמטר של המצב הנכנס שקוראים. אלה אינם שני שירותי אחסון. הערכים נשמרים לצורך שחזור, בלי לדרוש שאותו אובייקט Java מסוג Bundle יישאר בחיים.

לכן, אחרי שתי לחיצות וסיבוב, memoryCount מתחיל ב־0 במופע החדש כי לא שמרנו אותו, savedCount מקבל 2 מה־Bundle, ו־persistentCount מקבל 2 מקריאת SharedPreferences. רק אחר כך renderCounts() מציג אותם. בפרויקט אין override של onRestoreInstanceState(); השחזור של המונה שלנו כבר נעשה ב־onCreate(), וה־callback שבירושה מטפל בשחזור של המערכת.

מי שומר ומשחזר את ה־Fragment?

AndroidX FragmentManager מתאם את שמירת ה־Fragment כחלק משמירת ה־Activity המארח: הוא קורא ל־onSaveInstanceState() של ה־Fragment וכולל את מצבו במצב המנהל. ל־Activity ול־Fragment יש Bundles משלהם, ולא אוסף שטוח משותף של מפתחות. ראו שמירת מצב של Fragments.

ב־LifecycleFragment כבר כתבנו:

@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
    outState.putInt(SAVED_COUNT, count);
    super.onSaveInstanceState(outState);
}

במחלקה הזאת SAVED_COUNT הוא המפתח "fragment_count". בשחזור, המנהל מספק את המצב השמור ל־callbacks של יצירת ה־Fragment והתצוגה שלו. במעבדה קוראים את המונה ב־onViewCreated():

if (savedInstanceState != null) {
    count = savedInstanceState.getInt(SAVED_COUNT);
}

ה־Activity אינו קורא ידנית למתודת השמירה של ה־Fragment ואינו מעביר אליו את ה־Bundle של עצמו. בניסוי detach וחזרה עם Back,‏ count יכול להישאר בשדה של אותו מופע Fragment; עצם הריסת ה־View אינה מחייבת קריאת שמירה חדשה כדי שהמונה ישרוד.

האם ה־Bundle נכתב לקובץ?

ה־Bundle הוא תמונת מצב קטנה לשחזור, ולא קובץ שהקוד שלנו כותב או אחסון מתמשך. Android מחזיק מצב שמור מחוץ לתהליך האפליקציה כדי שיוכל לשחזר משימה קיימת אחרי הריגת תהליך בידי המערכת. תמונת המצב צריכה להיאסף לפני ההריגה: הריגת תהליך אינה נותנת לאפליקציה הזדמנות אחרונה להפעיל את ה־callback. סיום Activity,‏ Force stop או הסרת המשימה אינם תחליף לאחסון מתמשך. ראו מצב שמור לעומת אחסון מתמשך.

לעומת זאת, במעבדה מונה ההעדפות נכתב בנפרד בכל לחיצה שמעלה אותו:

preferences.edit().putInt(PERSISTENT_COUNT, persistentCount).apply();

יש כאן שני טריגרים: לחיצה משנה את השדות ומבקשת מיד כתיבה להעדפות; בקשת שמירה של Android מעתיקה בהמשך את savedCount ואת count של ה־Fragment לתמונות המצב שלהם.

בדקו את ההבנה: לאחר שתי לחיצות המונים מציגים 2, אבל עדיין לא התרחשה בקשת שמירה נוספת. איזו שורה תכניס את 2 ל־Bundle כשיגיע ה־callback? האם הריגת התהליך עכשיו מבטיחה שהערך האחרון יופיע בשחזור? הסבירו לפי זמן איסוף תמונת המצב.

4. איך פרויקט Fragments מציג מסכים ומחליף ביניהם?

MainActivity מציב אחד משלושה Fragments במיכל ריק. ב־activity_main.xml של Fragments נמצאים FragmentContainerView עם המזהה fragment_container וסרגל ניווט תחתון. למיכל אין android:name; לכן MainActivity.java בוחר את ה־Fragment בקוד.

אחרי טעינת פריסת ה־Activity,‏ onCreate() מחבר את מאזין הניווט ובוחר ב־Home ביצירה הראשונה:

binding.navView.setOnItemSelectedListener(item -> showFragment(item.getItemId()));
if (savedInstanceState == null) {
    showFragment(R.id.navigation_home);
}

showFragment(...) מתרגם את מזהי פריטי Home, Dashboard ו־Notifications ל־new HomeFragment(),‏ new DashboardFragment() ו־new NotificationsFragment(). לאחר מכן הוא בודק איזו מחלקת Fragment כבר מוצגת במיכל, ומחליף את המסך רק אם המיכל ריק או שמדובר במחלקה אחרת:

Fragment current = getSupportFragmentManager()
        .findFragmentById(R.id.fragment_container);
if (current == null || current.getClass() != fragment.getClass()) {
    getSupportFragmentManager().beginTransaction()
            .setReorderingAllowed(true)
            .replace(R.id.fragment_container, fragment)
            .commit();
}

getSupportFragmentManager() הוא אופן הגישה למנהל מתוך ה־Activity. setReorderingAllowed(true) מאפשר למנהל לתאם את שינויי המצב ומחזור החיים של העסקה. replace(...) מסיר את ה־Fragment הנוכחי מהמיכל ומוסיף את הנבחר, ו־commit() מתזמן את הביצוע. בחירה במסך שכבר מוצג אינה מחליפה אותו. מעבר למסך אחר ואז חזרה יוצר Fragment חדש; המימוש הזה אינו שומר מופע נפרד לכל לשונית.

התנאי savedInstanceState == null מונע בחירה מחודשת ב־Home בזמן יצירה מחדש של ה־Activity, ומאפשר ל־FragmentManager לשחזר את ה־Fragment שהוצג.

5. למה במחלקות הדוגמה אין onCreateView(), והאם Back מבטל החלפת לשונית?

כל Fragment מוסר את הפריסה למחלקת האב; החלפות הלשוניות אינן נרשמות במחסנית החזרה. בכל אחת משלוש המחלקות יש רק בנאי ציבורי ללא פרמטרים שמעביר ל־Fragment את משאב הפריסה. לדוגמה, HomeFragment.java:

public class HomeFragment extends Fragment {
    public HomeFragment() {
        super(R.layout.fragment_home);
    }
}

המימוש של onCreateView() שמתקבל בירושה טוען את הפריסה הזאת. Dashboard ו־Notifications משתמשים באותו דפוס עם fragment_dashboard ו־fragment_notifications. הפריסות מציגות תוויות מסך פשוטות, ולכן אין כאן צורך ב־View Binding או במאזיני כפתורים בתוך המחלקות. לעומת זאת, LifecycleFragment מממש בעצמו את onCreateView() כדי ליצור binding לתצוגה האינטראקטיבית, ומנקה אותו ב־onDestroyView().

עסקאות ההחלפה בפרויקט Fragments אינן קוראות ל־addToBackStack(...), ולכן Back אינו מחזיר ללשונית הקודמת. אלה שלושה מסכים מקבילים שהמשתמש בוחר מסרגל הניווט. במעבדת Lifecycle רשמנו בכוונה את detach(this) במחסנית, ולכן Back מבטל את הניתוק ויוצר מחדש את ה־View של אותו Fragment מנוהל.

בדקו את ההבחנה: במעבדת Lifecycle העלו את המונה, נתקו את התצוגה וחזרו עם Back. בפרויקט Fragments עברו מ־Home ל־Dashboard ולחצו Back. לפני ההפעלה נבאו באיזה ניסוי Back יהפוך את העסקה, והצביעו על הקריאה בקוד שקובעת זאת.

הרחבה: מי מפעיל כל callback של מחזור החיים, ומתי?

מפת ה־callbacks: מ־onCreate ו־onAttach ועד onDestroyView ו־onDetach

קודם מבדילים בין פעולה, callback ומצב

callback הוא מתודה שהמערכת קוראת לה כשהרכיב מגיע לנקודה מסוימת במחזור חייו. אנחנו עושים לה override כדי להגיב. איננו קוראים ל־onCreate() כדי לפתוח מסך או ל־onDestroyView() כדי להסיר תצוגה: מבקשים פעולה באמצעות ה־API המתאים, ו־Android או FragmentManager מנהלים את המעבר וקוראים ל־callbacks.

למשל, בכפתור הניתוק במעבדה יש שרשרת סיבתית:

  1. המשתמש לוחץ; מאזין הלחיצה שלנו מופעל.
  2. המאזין מבקש עסקת detach(this) ומגיש אותה באמצעות commit().
  3. כשהמנהל מבצע את העסקה, הוא מוריד את מצב ה־Fragment ומסיר את תצוגתו.
  4. במהלך המעבר נקראות onPause(),‏ onStop() ולבסוף onDestroyView(). הקוד שלנו מנקה את ה־binding ב־callback האחרון.

הפעולה היא הבקשה; ה־callback מודיע על שלב בביצועה. new LifecycleFragment() לבדו יוצר אובייקט Java, אבל אינו מציג אותו ואינו מפעיל את כל מחזור החיים. גם בנאי אינו onCreate().

מצב מתאר היכן הרכיב נמצא עכשיו; אירוע מתאר מעבר. ב־AndroidX המצבים המרכזיים הם INITIALIZED,‏ CREATED,‏ STARTED,‏ RESUMED ו־DESTROYED. אחרי onPause() חוזרים ל־STARTED; אחרי onStop() חוזרים ל־CREATED. אין ב־Lifecycle.State מצבים בשם PAUSED או STOPPED. לאירועים יש שמות כמו ON_PAUSE; אלה אינם מתודות שקוראים להן בקוד המעבדה. ראו מצבי Lifecycle ואירועי Lifecycle.

ה־callbacks של Activity: המסך המארח

הטבלה מתייחסת ל־Activity רגילה עם Views, כמו MainActivity במעבדה. אותם שמות מופיעים גם ב־Fragment, אבל כל קריאה שייכת לאובייקט שבו היא מתבצעת. ה־Activity אינו צריך לקרוא ידנית ל־fragment.onStart().

callback מה גורם לקריאה ומתי היא מגיעה? מה עושים בו, ומה לא להסיק ממנו?
onCreate(savedInstanceState) Android מאתחל מופע Activity חדש: בפתיחה ראשונה או ביצירה מחדש, למשל בסיבוב רגיל. טוענים פריסה ו־binding, מחברים מאזינים ומשחזרים מצב קטן מה־Bundle אם קיים. נקרא פעם לכל מופע, לא פעם לכל התקנת האפליקציה. במופע החדש השדות הרגילים מתחילים מחדש.
onStart() ה־Activity נעשה גלוי: אחרי יצירה או בחזרה ממצב עצור. מתחילים עדכונים שנחוצים כל עוד המסך גלוי. הוא יכול להיות גלוי בלי להיות המסך הפעיל לאינטראקציה.
onResume() ה־Activity נכנס למצב הפעיל לאינטראקציה; גם בחזרה לאחר הפרעה שלא הרסה אותו. מחדשים פעולה שהוגבלה לזמן הפעיל ונעצרה ב־onPause(). לא יוצרים בכל חזרה מאזיני כפתורים נוספים או מאפסים מונים.
onPause() ה־Activity יוצא מ־RESUMED, למשל כשמסך אחר מקבל קדימות. משהים פעולה שצריכה את המצב הפעיל. עדיין ייתכן שהמסך גלוי; זו אינה הוכחה ליציאה מהאפליקציה. שומרים את ה־callback קצר, בלי פעולת קובץ או רשת כבדה.
onStop() ה־Activity כבר אינו גלוי, למשל אחרי מעבר לאפליקציה אחרת שמכסה אותו. מפסיקים עדכונים שנחוצים רק למסך גלוי ומשחררים משאבים שנפתחו ב־onStart(). האובייקט עשוי להישאר בזיכרון; אין כאן איפוס אוטומטי של השדות.
onRestart() Activity שנעצר חוזר לפעילות באותו מופע, לפני onStart(). מאפשר תגובה לחזרה ממצב עצור. לא ייקרא במסלול של מופע חדש אחרי סיבוב או הריגת תהליך; שם מתחילים שוב ב־onCreate().
onDestroy() סיום מסודר של מופע, למשל אחרי finish() או במהלך יצירה מחדש עקב שינוי תצורה. משלימים ניקוי משאבים השייכים למופע. הקריאה אינה הוכחה שהמשתמש סיים: בסיבוב ייתכן שמסך חדש עומד להיווצר. בהריגת תהליך אין הבטחה שהמתודה תיקרא.

זוגות העבודה עוזרים לבחור מקום: משאב שצריך רק בזמן שהמסך גלוי מתאים בדרך כלל ל־onStart() / onStop(); משאב שצריך רק בזמן הפעיל מתאים ל־onResume() / onPause(). הבחירה תלויה במשאב ובחוויה הרצויה. אין צורך לממש כל callback רק מפני שהוא קיים. ראו מחזור חיי Activity ותיעוד Activity.

ומה לגבי שמירה ושחזור?

onSaveInstanceState(outState) היא בקשה לתמונת מצב לשחזור; היא אינה שם נוסף ל־onPause(), אינה מופעלת בכל לחיצה ואינה הבטחה לכתיבה לפני סגירה. במעבדה מכוונים ל־API 28 ומעלה, ולכן כשמתבצעת שמירת Activity היא באה אחרי onStop(). אין להוסיף אותה כרכיב חובה לכל רצף יציאה: בסיום יזום אין לצפות לשמירה לצורך שחזור אותו מסך.

onRestoreInstanceState(savedInstanceState) נקראת כאשר יש מצב Activity שמור לשחזר, אחרי onStart() ולפני onResume(). לעומתה, onCreate() נקראת גם כשאין מצב שמור, ולכן שם בודקים savedInstanceState != null. במעבדה כבר משחזרים את המונה ב־onCreate(); אין צורך לשחזר אותו שוב בשתי המתודות. הקריאות ל־super מאפשרות לתשתית לבצע גם את השמירה והשחזור שלה.

הסבר מעבר הערכים בין המופעים נמצא במסלול ה־Bundle. בפרט, הריגת תהליך אינה מפעילה סבב callbacks אחרון. שמירת מידע חשוב אינה יכולה להמתין ל־onDestroy().

ה־callbacks של Fragment: האובייקט והתצוגה אינם אותו הדבר

FragmentManager מתאם את מחזור חיי ה־Fragment לפי מצב המארח, עסקאות הניווט ומגבלות נוספות. Fragment אינו יכול להיות במצב גבוה מזה שהמארח מאפשר. לכן גם מעבר של ה־Activity לרקע משפיע על ה־Fragments שלו, אף שלא לחצנו על כפתור בתוך ה־Fragment.

1. חיבור למארח ואתחול האובייקט

onAttach(Context context) — עכשיו יש מארח. נקראת כשה־Fragment מתחבר למארח שלו, לפני onCreate(). זו הזדמנות לקבל תלות שהמארח מספק, למשל ממשק תקשורת ל־Activity. בשלב הזה עדיין אין עץ Views של ה־Fragment; חיבור ל־Activity אינו יצירת התצוגה. בבנאי לא מניחים ש־requireContext() כבר זמין.

onCreate(savedInstanceState) — אתחול ה־Fragment עצמו. נקראת לאחר החיבור, כשהמנהל מביא את המופע למצב CREATED. כאן מתאים לאתחל מידע שאינו תלוי ב־Views ולקרוא מצב שמור של ה־Fragment. היא אינה טוענת אוטומטית את ה־binding שלנו. בסיבוב רגיל נוצר Fragment חדש ונקראת שוב המתודה במופע החדש; בניתוק התצוגה וב־Back במעבדה אותו מופע נשאר, ולכן אין onCreate() נוספת.

2. יצירת מחזור תצוגה חדש

onCreateView(inflater, container, savedInstanceState) — צריך לבנות עץ Views. נקראת כשהמנהל צריך את תצוגת ה־Fragment, גם בפעם הראשונה וגם אחרי שתצוגה קודמת נהרסה. במעבדה יוצרים כאן FragmentLifecycleBinding ומחזירים את השורש. אובייקט Fragment אחד יכול לעבור כאן כמה פעמים, עם binding חדש בכל פעם. Fragment שמחזיר null אינו מקבל מחזור חיי View; המעבדה שלנו מחזירה View.

onViewCreated(view, savedInstanceState) — העץ קיים ואפשר לחבר אליו התנהגות. נקראת לאחר ש־onCreateView() סיפקה View תקף. כאן מחברים מאזיני כפתורים, מתאמים ותצפיות שמעדכנות את התצוגה. במעבדה קוראים כאן גם למצב המונה ול־renderCount(). קיום ה־View אינו אומר שכבר הוצג פריים או שמידותיו הסופיות חושבו; חישוב שדורש רוחב וגובה בפועל צריך להמתין לשלב הפריסה.

תצפית LiveData שמעדכנת Views קושרים בדרך כלל ל־getViewLifecycleOwner(), כדי שתוסר כשהתצוגה הזאת נהרסת. תצפית הקשורה רק ל־Fragment עלולה לחיות מעבר לתצוגה ולהגיע אל binding שכבר נוקה. זהו ההבדל המעשי בין שני בעלי מחזור החיים.

onViewStateRestored(savedInstanceState) — שחזור עץ התצוגה הושלם. נקראת אחרי onViewCreated() ואחרי שהמערכת שחזרה את מצב ה־Views הקודם, אם היה כזה, ולפני onStart(). השחזור האוטומטי עשוי לכלול למשל טקסט של EditText מזוהה. כשצריך להשלים התאמה שתלויה במצב הזה, זה המקום. המתודה נקראת גם ביצירה בלי מצב קודם, עם null; אין פירוש שמה שתמיד היה מה לשחזר.

3. הופעה, פעילות ונסיגה

callback ב־Fragment מה גורם לקריאה? המשמעות במעבדה
onStart() המנהל מעלה את ה־Fragment ל־STARTED, בהתאם למארח ולעסקה. התצוגה קיימת, אם ה־Fragment יצר תצוגה. אפשר לחדש עבודה המיועדת לזמן הצגת המסך. המצב עשוי להיות מוגבל ל־STARTED, למשל בעמוד סמוך ב־ViewPager2.
onResume() המנהל מעלה אותו ל־RESUMED, כשהוא מוכן לאינטראקציה ולאחר השלמת אפקטי המעבר הרלוונטיים. אין צורך לבנות binding שוב: הוא נוצר קודם. גם חזרה בלי הרס תצוגה יכולה להביא לקריאה הזאת.
onPause() ה־Fragment יוצא מ־RESUMED: בגלל המארח, ניווט או עסקה שמורידה את מצבו. משהים עבודה המיועדת לזמן הפעיל בלבד. זו עדיין אינה הודעה שה־View נהרס.
onStop() מצבו יורד מ־STARTED ל־CREATED, למשל כשהמארח נעצר או כשה־Fragment יוצא מהמסך. עוצרים עבודה המיועדת לזמן הגלוי. עדיין לא מנקים את ה־binding רק בגלל העצירה; הרס התצוגה הוא שלב נפרד.

ל־Fragment אין onRestart(). אם הוא חוזר ממצב עצור באותו מחזור תצוגה, המסלול הוא onStart() ואז onResume(). אם התצוגה נהרסה, תחילה נדרש מחזור יצירת View חדש. ראו מפת מחזור חיי Fragment.

4. פירוק: שלוש מתודות שונות

onDestroyView() — הסתיים מחזור התצוגה הנוכחי. מגיעה כשהמנהל מפרק את עץ התצוגה, לאחר השלמת אפקטי היציאה הרלוונטיים. במעבדה זה קורה בעקבות detach(); זה קורה גם בהסרה או בהרס המארח. כאן מנקים binding והפניות אחרות ל־Views של העץ הישן, ומבטלים עבודה שאינה מנוהלת אוטומטית ושעלולה לעדכן אותם. ה־Fragment עצמו עשוי להישאר עם count שלו וליצור תצוגה חדשה בהמשך.

onDestroy() — הסתיים מחזור חיי מופע ה־Fragment. מגיעה בפירוק מסודר של המופע, למשל בהסרה סופית או בהרס המנהל. אין להשאיר ניקוי View עד כאן, כי ייתכן ש־onDestroyView() התרחשה הרבה קודם. גם כאן אין הבטחה לקריאה במקרה שהתהליך נהרג; השם אינו הבטחה שה־garbage collector כבר פינה את אובייקט Java.

onDetach() — נותק הקשר למארח. מגיעה בסוף הניתוק מן המארח, אחרי שלבי מחזור החיים שקדמו לה. כאן משחררים הפניה לממשק של ה־Activity אם שמרנו כזאת ב־onAttach(). אין להסתמך עוד על קבלת Activity או Context מן ה־Fragment אחרי הניתוק.

FragmentTransaction.detach(fragment) ו־Fragment.onDetach() אינם זוג של בקשה והודעה. עסקת detach במעבדה משאירה את ה־Fragment מנוהל במצב CREATED והורסת את תצוגתו; היא אינה מנתקת אותו מהמארח ואינה קוראת ל־onDetach(). היפוכה יוצר View חדש, בלי onAttach() או onCreate() נוספות באותו מופע. ראו משמעות attach ו־detach בעסקאות.

5. שמירה ושמות שאפשר לפגוש בקוד אחר

גם ל־Fragment יש onSaveInstanceState(outState), שהמנהל מפעיל כשנדרש לשמור את מצבו. אין ל־Fragment callback בשם onRestoreInstanceState(); המצב מגיע כפרמטר ל־onCreate(),‏ onCreateView(),‏ onViewCreated() ו־onViewStateRestored(), לפי השלב. בוחרים את מקום הקריאה לפי סוג המידע, ולא מאפסים אותו בכל שלב. שמירת מצב Fragment מפרידה בין שדות, מצב Views ומצב שמור.

בקוד ישן אפשר לפגוש onActivityCreated(); הוא מיושן. לא מוסיפים אותו למעבדה: אתחול ה־Fragment שייך ל־onCreate(), ואתחול התצוגה ל־onViewCreated(). גם onHiddenChanged() עשוי להופיע: זו הודעה על שינוי דגל ההסתרה בעקבות hide() / show(), ולא שלב נוסף במחזור החיים. הפעולות האלה לבדן אינן משנות את מצב מחזור החיים, ולכן Fragment מוסתר אינו בהכרח עצור. ראו תיעוד callbacks של Fragment.

מחברים את השמות לפעולות אמיתיות

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

פתיחת Activity חדשה והפרעה זמנית

New Activity:
onCreate -> onStart -> onResume

Temporary interruption, without stopping:
onPause -> onResume

Home, then return (same Activity instance, not destroyed):
onPause -> onStop
onRestart -> onStart -> onResume

Home אינו בקשה להרוס את המסך. בחזרה באותו מופע השדות נשארים ואין onCreate() חדשה. אם המערכת הרגה בינתיים את התהליך, או שהאפשרות Don't keep activities הרסה את ה־Activity, התנאי “אותו מופע” כבר אינו מתקיים; החזרה דורשת יצירה מחדש. Don't keep activities הורסת Activities, אך אינה בהכרח הורגת את תהליך האפליקציה, ולכן אינה תחליף לניסוי בהריגת תהליך בידי המערכת. חלון שמכסה חלק מהמסך עשוי לגרום רק ל־pause; פתיחת Dialog רגיל בתוך ה־Activity אינה מחייבת pause של ה־Activity.

יצירת Fragment והניסוי של הרס ה־View

First Fragment display:
onAttach -> onCreate
onCreateView -> onViewCreated -> onViewStateRestored
onStart -> onResume

Destroy Fragment View (detach transaction):
onPause -> onStop -> onDestroyView

Back reverses that transaction (same Fragment instance):
onCreateView -> onViewCreated -> onViewStateRestored
onStart -> onResume

Final removal of a resumed Fragment with a View:
onPause -> onStop -> onDestroyView -> onDestroy -> onDetach

במסלול האחרון הכוונה להסרה סופית, ללא שימור ה־Fragment במחסנית החזרה. בהחלפה עם addToBackStack(), ה־Fragment הקודם יכול להישאר מנוהל בלי View; Back עשוי להחזיר את תצוגתו. עצם המילה Back אינה קובעת אם תהיה הריסה או יצירה: צריך לבדוק איזו פעולה במחסנית היא מבטלת. במעבדה היא מבטלת ניתוק, ובדוגמת הלשוניות אין בכלל עסקאות החלפה במחסנית.

סיבוב רגיל לעומת הריגת תהליך

בסיבוב שיוצר מחדש את ה־Activity, הישן עובר pause, עצירה, שמירת מצב והריסה, והחדש מתחיל ב־onCreate(savedInstanceState). גם ה־Fragment הרגיל משוחזר כמופע חדש ומקבל מחזור תצוגה חדש. לכן אין להסיק מהישרדות count בניסוי detach שהוא ישרוד בשדה גם בסיבוב: אלה מנגנונים שונים.

בהריגת תהליך בידי המערכת ייתכן שלא נראה onDestroyView(),‏ onDestroy() או onDetach() כלל. בשחזור משימה קיימת נעזרים בתמונה שנשמרה קודם; אין callback שמודיע לשדה הישן “העבר את עצמך עכשיו”. Force stop ופתיחה חדשה אינם אותו ניסוי.

בדקו הבנה לפני שמסתכלים ב־Logcat

לכל תחזית כתבו שלושה דברים: מה הפעולה, איזה אובייקט נשאר, ואילו Views עדיין תקפים.

  1. העלינו את מונה ה־Fragment ל־3, ניתקנו את תצוגתו וחזרנו עם Back. האם תופיע onCreate() נוספת? מה מציג את 3 בעץ החדש?
  2. עברנו עם Home וחזרנו בלי הרס Activity. האם צריך לחבר שוב את מאזיני הכפתורים? איזה callback של Activity מופיע בחזרה ואינו קיים ב־Fragment?
  3. סובבנו את המסך. האם onDestroy() מוכיחה שהמשתמש סיים? האם אותו שדה Java עבר למופע החדש?
  4. קוד רשת שהתחיל קודם מנסה לכתוב ל־binding אחרי onDestroyView(). האם העובדה שה־Fragment עוד קיים הופכת את הכתיבה לתקינה?
תשובות והכוונה לבדיקה
  1. באותו מופע אין onCreate() נוספת. נוצרים binding ו־View חדשים, ו־renderCount() מציג את המונה דרך ה־binding החדש.
  2. המאזינים נשארים מחוברים לתצוגה הקיימת. ה־Activity עובר onRestart() לפני onStart(); ה־Fragment אינו מקבל onRestart().
  3. לא ולא. המופע הישן נהרס בגלל שינוי התצורה; הערך חוזר רק אם קראנו אותו ממקור מתאים, כמו Bundle או העדפות.
  4. לא. התוצאה צריכה לעדכן מצב שאפשר להציג בתצוגה תקפה, או שהעבודה/העדכון צריכים להתבטל עם מחזור התצוגה. עצם בדיקת קיום ה־Fragment אינה מספיקה.

במעבדה הקיימת לא עשינו override לכל ה־callbacks: Logcat מציג רק את אלה שבהם הוספנו רישום. היעדר שורת log אינו הוכחה שה־callback לא נקרא. לבדיקה ממוקדת אפשר להציב breakpoint במימוש שבירושה ב־Android Studio, או להוסיף זמנית רישום ל־callback אחד, תוך שמירה על חתימתו ועל קריאת super הנדרשת. השוו תמיד גם את שם הרכיב ואת מספר המופע; שתי שורות onCreate של מופעים שונים אינן שתי קריאות באותו מופע.