מפת המעבדות · השיעור הקודם: הוספת תמיכת Compose
בסוף המעבדה מסך Java/XML הקיים פותח קטלוג Compose. בקטלוג מחפשים כותרת, מסמנים Favorite מקומי, פותחים פריט במסלול ניווט, רואים TextView ישן בתוך Compose, וחוזרים לרשימה. בדיקת UI עוברת מסלול חיפוש־פריט־חזרה ומשחזרת את החיפוש אחרי יצירה מחדש של ה־Activity.
בסיס ההשוואה בפרויקט topics הוא master; ענף התוצאה הוא codex/compose-ui-system. זהו ענף Kotlin ייעודי בתוך סדרה שברובה Java/XML, משום ש־Compose נכתבת בפונקציות Kotlin @Composable. לא מחליפים את כל מסך הבסיס רק כדי ללמוד את המערכת החדשה: השינוי ב־Java הוא כפתור ו־Intent, ו־ComposeActivity.kt היא קובץ חדש.
מפת אחריות של המסך
| מושג | איפה בקוד? | מה קורה למשתמש? |
|---|---|---|
| State | query ו־favorite |
חיפוש וסימון מעדכנים UI |
| Recomposition | קריאה חוזרת לפונקציות שתלויות ב־state | הרשימה והכוכב משתנים בלי notifyDataSetChanged |
| Layout/list | Column, Row, LazyColumn |
ארבעה ספרים, סינון, שורות ממוחזרות |
| Navigation | NavHost, book/{id} |
לחיצה פותחת פריט; Back חוזר |
| Interop | Java Activity → Compose Activity; AndroidView |
מעבר מדורג בין Views ל־Compose |
| Testing | ComposeCatalogTest |
מסלול משתמש ושחזור אחרי recreate |
חושבים בתיאור מסך מתוך מצב
ב־Views, קוד כמו setText משנה רכיב שכבר קיים. ב־Compose הפונקציה מתארת מה צריך להופיע עבור המצב הנוכחי; שינוי state מאפשר לחשב תיאור חדש. recomposition אינה בקשה להפעיל מחדש כל פעולה עסקית. אין לשלוח HTTP או לשמור נתון רק משום שגוף Composable רץ שוב; פעולות כאלה צריכות בעלות ותזמון מפורשים.
flowchart LR
Q["query state"] --> F["Filter book data"]
F --> L["LazyColumn with stable book keys"]
L --> E["User edits query"]
E --> Q
D["Detail state"] --> C["Compose Text"]
D --> U["AndroidView update"]
X["AndroidView factory"] --> U
mutableStateOf מאפשרת ל־Compose לעקוב אחר קריאות ושינויים; remember משמרת ערך בין recompositions של אותה נוכחות בממשק; rememberSaveable מוסיפה שחזור עבור טיפוסים שניתנים לשמירה. אף אחד מהם אינו מסד נתונים. גם key של ספר אינה שמירה לדיסק: היא עוזרת לשייך שורה לזהות שלה כאשר הסדר משתנה.
favorite המקומית בשורה מלמדת תגובת UI. כאשר שורה יוצאת מן ההרכב בגלל סינון, אין להסיק מן הדוגמה שהבחירה תישמר בכל תרחיש. אם צריך לשתף Favorite בין הרשימה למסך פרטים, נעלה את הבעלות אל בעל מצב משותף ונעביר ערך ו־callback למטה. כך שורה אינה מחזיקה אמת נפרדת מן הפרטים.
AndroidView.factory יוצרת View כשהיא נדרשת; update מסנכרנת אותה עם הנתון העדכני. קריאה אחת ל־setText רק ב־factory הייתה משאירה טקסט ישן בשינוי state. עץ הסמנטיקה של Compose ועץ Views אינם אותו עץ בדיקה; צריך לבחור כלי לפי הרכיב שמאמתים. בקובצי Kotlin נשתמש ב־KDoc (/** ... */) לפני פונקציות, המקביל לתפקיד Javadoc בקוד Java.
עצרו ונבאו
המשתמש הקליד Android ואז הפעיל scenario.recreate. מדוע החיפוש יכול לחזור בלי לשמור את הרשימה המסוננת? כתבו תחזית לפני פתיחת ההסבר, ואז הצביעו על המשתנה או התנאי בקוד שמצדיקים אותה.
בדיקת ההבנה
query היא ערך קטן ב־rememberSaveable. אחרי שחזור מחשבים מחדש את הרשימה מתוך books ו־query. שומרים קלט יציב ומפיקים ממנו תצוגה; אין צורך לשמור עותק נוסף של אותה תוצאה נגזרת.
1. בונים בשני מחסומים תקינים
ב־Gradle Scripts > libs.versions.toml, הוסיפו גרסת Kotlin 2.3.21, Compose BOM 2026.09.00, ו־Navigation Compose 2.10.2 (הגרסאות בענף התוצאה). הוסיפו aliases ל־Compose compiler plugin, BOM, Material3, foundation, UI, Activity Compose, Navigation Compose ולספריות בדיקה. ב־Gradle Scripts > build.gradle.kts (Project), בתוך plugins, הוסיפו:
alias(libs.plugins.compose.compiler) apply false
הקוד המשלים בהמשך מראה את השינויים המדויקים בטבלת הגרסאות ובקובץ Gradle של המודול. ב־app/build.gradle.kts הפעילו את plugin זה ואת buildFeatures.compose = true, ואז הוסיפו רק את ספריות Compose הנצרכות; הכניסו את BOM גם ל־androidTestImplementation.
הריצו ./gradlew :app:assembleDebug כעת, לפני יצירת קובץ Kotlin. זהו checkpoint שמפריד שגיאת Gradle/גרסה משגיאת קוד UI. בסיס הפרויקט משתמש ב־AGP 9.4 עם תמיכת Kotlin מובנית; אין להוסיף כאן org.jetbrains.kotlin.android ישן רק כי מדריך משנים קודמות השתמש בו. מדריך Android ל־built-in Kotlin ו־תוסף Compose Compiler מסבירים את החלוקה. Compose BOM מתאמת את גרסאות ספריות Compose, אך לא את Navigation או Activity.
2. מעבר קטן מתוך מסך Java
ב־app > res > layout > activity_main.xml, השאירו את ConstraintLayout ואת TextView של התבנית. תנו ל־TextView מזהה hello וטקסט @string/compose_intro, והוסיפו מתחתיו Button id=open_compose עם constraint ל־@id/hello. ב־app > res > values > strings.xml הוסיפו את שני הטקסטים. ב־MainActivity.java הוסיפו import ל־Intent ואחרי setContentView:
binding.openCompose.setOnClickListener(
v -> startActivity(new Intent(this, ComposeActivity.class)));
ב־app > manifests > AndroidManifest.xml, בתוך <application>, רשמו <activity android:name=".ComposeActivity" android:exported="false" />. המסך הפנימי אינו נקודת כניסה חיצונית. זהו interop ברמת Activity: View Binding ממשיכה לעבוד במסך הראשון, Compose מנהלת את השני.
3. State גורם למסך להתעדכן
צרו ComposeActivity.kt ב־app > kotlin+java > com.example.topics. המחלקה יורשת ComponentActivity וקוראת setContent { CatalogApp() }. בתוך מסלול books, חיפוש נראה כך:
// Save the small query value for recreation, not the entire filtered list.
var query by rememberSaveable { mutableStateOf("") }
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Search titles") },
modifier = Modifier.fillMaxWidth().testTag("search")
)
LazyColumn {
items(books.filter { it.title.contains(query, ignoreCase = true) },
key = { it.id }) { book ->
// Row with title and Favorite action
}
}
onValueChange משנה את query; Compose מריצה מחדש את החלקים שקוראים את הערך והרשימה המסוננת מתעדכנת. אין צורך לפנות ידנית ל־TextView. rememberSaveable שומרת טקסט פשוט דרך state של ה־Activity ולכן החיפוש נשאר אחרי סיבוב/יצירה מחדש. key = { it.id } נותן זהות יציבה לשורה גם כשהרשימה מסוננת. לכל שורה בקוד המשלים favorite מקומי באמצעות rememberSaveable(book.id) ולחצן ☆/★. זהו state של הדגמת UI; הוא אינו מסד או מקור אמת בין מכשירים. מדריך state מסביר state hoisting כשכמה מסכים צריכים לחלוק נתון.
safeDrawingPadding() מרחיקה את תוכן המסך מ־system bars ו־display cutout. זו אחריות ה־insets של מסך Compose. Column מסדר רכיבים מלמעלה למטה, Row את הכותרת והכפתור זה לצד זה, ו־Modifier.padding/fillMaxWidth מתארים גודל ומרווח. LazyColumn מרכיבה שורות לפי הצורך, בדומה למטרת RecyclerView, אבל API העדכון שונה. השוו את מדריך הרשימות ב־Compose למעבדת RecyclerView.
4. ניווט עם מזהה, לא עם אובייקט שלם
CatalogApp משתמשת ב־rememberNavController() וב־NavHost(startDestination = "books"). לחיצה על כותרת שורה מבצעת nav.navigate("book/${book.id}"). ביעד book/{id} קוראים את id מ־arguments, מוצאים את הספר ברשימה ומציגים Book not found אם אינו קיים. כפתור Back to books קורא popBackStack(). העברת ID מאפשרת למסך הפרטים לטעון/לאמת מחדש; אל תעבירו אובייקט גדול או View עצמו כארגומנט ניווט. מדריך Navigation Compose מרחיב על routes ו־back stack.
במסך הפרטים נמצאת דוגמת interop הפוכה:
AndroidView(
// Create once for this View instance; synchronize changing data in update.
factory = { context -> TextView(context) },
update = { view -> view.text = "Classic TextView for ID: ${book?.id ?: "?"}" },
modifier = Modifier.padding(vertical = 16.dp)
)
factory יוצרת View, ו־update מסנכרנת אותה עם state כשה־Composable מתעדכנת. כך אפשר להעביר רכיב קיים בהדרגה. אין כאן סיבה מוצרית להעדיף TextView על Text; הוא מכוון להראות את הגבול בין שתי מערכות ה־UI. מדריך Views בתוך Compose מפרט את הכלי.
5. בדיקה שמפרידה בין מסך למנגנון
ב־app > kotlin+java > com.example.topics תחת source set androidTest, צרו ComposeCatalogTest.kt. createAndroidComposeRule<ComposeActivity>() מפעילה את Activity שכבר קוראת setContent. הבדיקה מכניסה Android לשדה testTag("search"), פותחת book-b2, מאשרת את כותרת הפריט, חוזרת, מוודאת ש־book-b1 אינו ברשימה, ומבצעת scenario.recreate() כדי לבדוק שהחיפוש נשמר. ב־מדריך בדיקות Compose יש פעולות סמנטיות כמו onNodeWithTag, performTextInput ו־assertIsDisplayed.
./gradlew :app:connectedDebugAndroidTest עבר באמולטור. בדיקת ה־UI הראשונה נכשלה כשניסתה למצוא את תוכן ה־TextView הישן כצומת סמנטי של Compose; תוקנה לבדוק את כותרת Compose במסך הפרטים, ואת ה־View הישן בודקים ידנית. בדיקה לא צריכה להניח שכל View מוטמע מופיע כמו Text בעץ הסמנטי של Compose.
הקוד המשלים במלואו
הקטעים הגלויים בשיעור ממקדים את הרעיון; הקבצים הבאים משלימים את כל הקוד הדרוש, עם תיעוד והערות. קראו את השינוי יחד עם ההסבר שמעליו. הם חלק מן השיעור ואינם דורשים פתיחת ענף דוגמה או אתר שפורסם.
libs.versions.toml
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/24/libs.versions.toml.md ביחס לשיעור.
הקוד המלא והשינויים עבור libs.versions.toml
ב־libs.versions.toml החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
material = "1.14.0"
activityKtx = "1.13.0"
constraintlayout = "2.2.2"
+kotlin = "2.3.21"
+composeBom = "2026.09.00"
+navigationCompose = "2.10.2"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
⁞
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
+compose-ui = { group = "androidx.compose.ui", name = "ui" }
+compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityKtx" }
+navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
-
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
build.gradle.kts
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/24/build.gradle.kts.md ביחס לשיעור.
הקוד המלא והשינויים עבור build.gradle.kts
ב־build.gradle.kts החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
plugins {
alias(libs.plugins.android.application)
+ alias(libs.plugins.compose.compiler)
}
android {
⁞
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
-
buildFeatures {
viewBinding = true
+ compose = true
}
}
⁞
implementation(libs.appcompat)
implementation(libs.constraintlayout)
implementation(libs.material)
+ implementation(platform(libs.compose.bom))
+ implementation(libs.activity.compose)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.ui)
+ implementation(libs.navigation.compose)
testImplementation(libs.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.ext.junit)
+ androidTestImplementation(platform(libs.compose.bom))
+ androidTestImplementation(libs.compose.ui.test.junit4)
+ debugImplementation(libs.compose.ui.test.manifest)
}
MainActivity.java
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/24/MainActivity.java.md ביחס לשיעור.
הקוד המלא והשינויים עבור MainActivity.java
ב־MainActivity.java החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
package com.example.topics;
import android.os.Bundle;
+import android.content.Intent;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
⁞
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);
EdgeToEdge.enable(this);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
+ binding.openCompose.setOnClickListener(v -> startActivity(new Intent(this, ComposeActivity.class)));
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;
ComposeActivity.kt
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/24/ComposeActivity.kt.md ביחס לשיעור.
הקוד המלא והשינויים עבור ComposeActivity.kt
צרו ComposeActivity.kt במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.
package com.example.topics
import android.os.Bundle
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
private data class Book(val id: String, val title: String)
private val books = listOf(
Book("b1", "Ada and Algorithms"),
Book("b2", "Android Patterns"),
Book("b3", "Data Structures"),
Book("b4", "Signals and Sensors")
)
class ComposeActivity : ComponentActivity() {
/** Creates the Compose host; its content function describes UI from current state. */
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { CatalogApp() }
}
}
/** Owns this demonstration's navigation; each route describes its current UI state. */
@Composable
private fun CatalogApp() {
val nav = rememberNavController()
MaterialTheme {
NavHost(navController = nav, startDestination = "books") {
composable("books") {
// Save a small input value, not a copied filtered list.
var query by rememberSaveable { mutableStateOf("") }
Column(Modifier.fillMaxSize().safeDrawingPadding().padding(24.dp)) {
Text("Books", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Search titles") },
modifier = Modifier.fillMaxWidth().testTag("search")
)
LazyColumn {
items(books.filter { it.title.contains(query, ignoreCase = true) },
key = { it.id }) { book ->
// This state belongs to this row composition, not a persistent database.
var favorite by rememberSaveable(book.id) { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Text(book.title, modifier = Modifier.weight(1f)
.clickable { nav.navigate("book/${book.id}") }
.testTag("book-${book.id}"))
TextButton(onClick = { favorite = !favorite }) {
Text(if (favorite) "★" else "☆")
}
}
}
}
}
}
composable("book/{id}") { entry ->
val id = entry.arguments?.getString("id")
val book = books.find { it.id == id }
Column(Modifier.fillMaxSize().safeDrawingPadding().padding(24.dp)) {
Text(book?.title ?: "Book not found",
style = MaterialTheme.typography.headlineMedium)
AndroidView(
// Create the legacy View here; keep changing data in update.
factory = { context -> TextView(context) },
update = { view -> view.text = "Classic TextView for ID: ${book?.id ?: "?"}" },
modifier = Modifier.padding(vertical = 16.dp)
)
Button(onClick = { nav.popBackStack() }) { Text("Back to books") }
}
}
}
}
}
ComposeCatalogTest.kt
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/24/ComposeCatalogTest.kt.md ביחס לשיעור.
הקוד המלא והשינויים עבור ComposeCatalogTest.kt
צרו ComposeCatalogTest.kt במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.
package com.example.topics
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import org.junit.Rule
import org.junit.Test
class ComposeCatalogTest {
@get:Rule val rule = createAndroidComposeRule<ComposeActivity>()
/** Checks the semantic UI route and query restoration after Activity recreation. */
@Test
fun searchOpenAndReturn() {
rule.onNodeWithTag("search").performTextInput("Android")
rule.onNodeWithTag("book-b2").assertIsDisplayed().performClick()
rule.onNodeWithText("Android Patterns").assertIsDisplayed()
rule.onNodeWithText("Back to books").performClick()
rule.onNodeWithTag("book-b2").assertIsDisplayed()
rule.onNodeWithTag("book-b1").assertDoesNotExist()
rule.activityRule.scenario.recreate()
rule.onNodeWithTag("book-b2").assertIsDisplayed()
rule.onNodeWithTag("book-b1").assertDoesNotExist()
}
}
activity_main.xml
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/24/activity_main.xml.md ביחס לשיעור.
הקוד המלא והשינויים עבור activity_main.xml
ב־activity_main.xml החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
tools:context=".MainActivity">
<TextView
+ android:id="@+id/hello"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Hello World!"
+ android:text="@string/compose_intro"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
+ <Button
+ android:id="@+id/open_compose"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:text="@string/open_compose"
+ app:layout_constraintTop_toBottomOf="@id/hello"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
</androidx.constraintlayout.widget.ConstraintLayout>
strings.xml
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/24/strings.xml.md ביחס לשיעור.
הקוד המלא והשינויים עבור strings.xml
ב־strings.xml החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
<resources>
<string name="app_name">topics</string>
+ <string name="compose_intro">The Java/XML screen opens a Compose book catalog.</string>
+ <string name="open_compose">Open Compose catalog</string>
</resources>
משימת העברה
הפכו את ה־Favorite לנתון שנשמר ב־Room: מי מחזיק את source of truth, איזה state יגיע ל־Composable, ומה יקרה בסיבוב ובחזרה מהמסך השני? השוו את החוזה למעבדת Room, וכתבו בדיקה שמתחילה מחדש את התהליך כדי להוכיח שמירה אמיתית.