שיקולים נוספים

ההעברה מ-Views ל-Compose קשורה רק לממשק המשתמש, אבל יש הרבה דברים שצריך לקחת בחשבון כדי לבצע העברה בטוחה וצבירתית. בדף הזה מפורטים כמה שיקולים שצריך לקחת בחשבון כשעוברים מאפליקציה מבוססת-תצוגה ל-Compose.

העברת העיצוב של האפליקציה

מערכת העיצוב המומלצת ליצירת עיצוב מותאם אישית לאפליקציות ל-Android היא Material Design.

באפליקציות מבוססות-תצוגה יש שלוש גרסאות של Material:

  • Material Design 1 באמצעות ספריית AppCompat (כלומר Theme.AppCompat.*)
  • Material Design 2 באמצעות ספריית MDC-Android (כלומר Theme.MaterialComponents.*)
  • Material Design 3 באמצעות ספריית MDC-Android (כלומר Theme.Material3.*)

באפליקציות Compose יש שתי גרסאות של Material:

  • Material Design 2 באמצעות ספריית Compose Material (כלומר androidx.compose.material.MaterialTheme)
  • Material Design 3 באמצעות הספרייה Compose Material 3 (כלומר androidx.compose.material3.MaterialTheme)

מומלץ להשתמש בגרסה העדכנית ביותר (Material 3) אם מערכת העיצוב של האפליקציה מאפשרת זאת. יש מדריכים להעברה גם ל-Views וגם ל-Compose:

כשיוצרים מסכים חדשים ב-Compose, בלי קשר לגרסה של Material Design שבה אתם משתמשים, חשוב להחיל MaterialTheme לפני כל רכיב Compose שמפיק ממשק משתמש מספריות Material של Compose. רכיבי Material (Button, ‏ Text וכו') תלויים בקיומו של MaterialTheme, וההתנהגות שלהם לא מוגדרת בלעדיו.

בכל הדוגמאות ל-Jetpack Compose נעשה שימוש בעיצוב Compose מותאם אישית שנבנה על גבי MaterialTheme.

מידע נוסף זמין במאמרים מערכות עיצוב ב-Compose והעברת נושאי XML ל-Compose.

אם אתם משתמשים ברכיב הניווט באפליקציה, תוכלו לקרוא מידע נוסף במאמרים ניווט באמצעות Compose – יכולת פעולה הדדית והעברת ניווט ב-Jetpack ל-Navigation Compose.

בדיקה של ממשק המשתמש המשולב של Compose/Views

אחרי העברת חלקים מהאפליקציה ל-Compose, חשוב מאוד לבדוק שהכול תקין.

כשפעילות או קטע משתמשים ב-Compose, צריך להשתמש ב-createAndroidComposeRule במקום ב-ActivityScenarioRule. createAndroidComposeRule משלב את ActivityScenarioRule עם ComposeTestRule שמאפשר לבדוק את הקוד בזמן הכתיבה והצגה.

class MyActivityTest {
    @Rule
    @JvmField
    val composeTestRule = createAndroidComposeRule<MyActivity>()

    @Test
    fun testGreeting() {
        val greeting = InstrumentationRegistry.getInstrumentation()
            .targetContext.resources.getString(R.string.greeting)

        composeTestRule.onNodeWithText(greeting).assertIsDisplayed()
    }
}

מידע נוסף על בדיקה זמין במאמר בדיקת הפריסה של Compose. למידע על יכולת פעולה הדדית עם מסגרות לבדיקת ממשק משתמש, ראו יכולת פעולה הדדית עם Espresso ויכולת פעולה הדדית עם UiAutomator.

שילוב של Compose עם ארכיטקטורת האפליקציה הקיימת

דפוסי ארכיטקטורה של Unidirectional Data Flow‏ (UDF) פועלים בצורה חלקה עם Compose. אם במקום זאת האפליקציה משתמשת בסוגים אחרים של דפוסי ארכיטקטורה, כמו Model View Presenter ‏ (MVP), מומלץ להעביר את החלק הזה של ממשק המשתמש ל-UDF לפני או במהלך ההטמעה של Compose.

שימוש ב-ViewModel ב'כתיבה מהירה'

אם אתם משתמשים בספרייה Architecture ComponentsViewModel, אתם יכולים לגשת ל-ViewModel מכל רכיב שאפשר ליצור ממנו קומפוזיציה, על ידי קריאה לפונקציה viewModel(), כפי שמוסבר במאמר Compose וספריות אחרות.

כשעוברים ל-Compose, חשוב להיזהר כשמשתמשים באותו סוג ViewModel ברכיבים שונים של Compose, כי רכיבי ViewModel פועלים בהתאם להיקפים של מחזור החיים של התצוגה. ההיקף יהיה הפעילות, החלק או תרשים הניווט של המארח, אם נעשה שימוש בספריית הניווט.

לדוגמה, אם הרכיבים הניתנים לשילוב מתארחים בפעילות, הפונקציה viewModel() תמיד מחזירה את אותה מכונה, שמתבטלת רק בסיום הפעילות. בדוגמה הבאה, אותו משתמש ('user1') מקבל הודעה פעמיים כי נעשה שימוש חוזר באותה מכונה של GreetingViewModel בכל הרכיבים הניתנים לשילוב בפעילות המארח. המכונה הראשונה של ViewModel שנוצרת משמשת שוב ברכיבים אחרים.

class GreetingActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MaterialTheme {
                Column {
                    GreetingScreen("user1")
                    GreetingScreen("user2")
                }
            }
        }
    }
}

@Composable
fun GreetingScreen(
    userId: String,
    viewModel: GreetingViewModel = viewModel(  
        factory = GreetingViewModelFactory(userId)  
    )
) {
    val messageUser by viewModel.message.observeAsState("")
    Text(messageUser)
}

class GreetingViewModel(private val userId: String) : ViewModel() {
    private val _message = MutableLiveData("Hi $userId")
    val message: LiveData<String> = _message
}

class GreetingViewModelFactory(private val userId: String) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return GreetingViewModel(userId) as T
    }
}

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

@Composable
fun MyApp() {
    NavHost(rememberNavController(), startDestination = "profile/{userId}") {
        /* ... */
        composable("profile/{userId}") { backStackEntry ->
            GreetingScreen(backStackEntry.arguments?.getString("userId") ?: "")
        }
    }
}

מקור האמת של המצב

כשמשתמשים ב-Compose בחלק אחד של ממשק המשתמש, יכול להיות ש-Compose וקוד המערכת של View יצטרכו לשתף נתונים. כשהדבר אפשרי, מומלץ להכיל את המצב המשותף הזה בתוך סוג אחר שעומד בשיטות המומלצות ל-UDF ששתי הפלטפורמות משתמשות בהן. לדוגמה, ב-ViewModel שחושף את המקור של הנתונים המשותפים כדי לשדר עדכוני נתונים.

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

כתיבת תוכן כמקור האמת

משתמשים ב-composable‏ SideEffect כדי לפרסם את המצב של Compose בקוד שאינו של Compose. במקרה כזה, מקור האמת נשמר ב-composable ששולח עדכוני מצב.

לדוגמה, ייתכן שספריית הניתוח תאפשר לכם לפלח את אוכלוסיית המשתמשים על ידי צירוף מטא-נתונים מותאמים אישית (מאפייני משתמשים בדוגמה הזו) לכל האירועים הבאים בניתוח. כדי לעדכן את הערך של SideEffect ולשלוח את סוג המשתמש של המשתמש הנוכחי לספריית Analytics, משתמשים ב-SideEffect.

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

מידע נוסף זמין במאמר תופעות לוואי ב-Compose.

המערכת כמקור המידע האמין

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

בדוגמה הבאה, CustomViewGroup מכיל TextView ו-ComposeView עם TextField שאפשר ליצור ממנו רכיב. ב-TextView צריך להופיע התוכן שהמשתמש מקלידים ב-TextField.

class CustomViewGroup @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {

    // Source of truth in the View system as mutableStateOf
    // to make it thread-safe for Compose
    private var text by mutableStateOf("")

    private val textView: TextView

    init {
        orientation = VERTICAL

        textView = TextView(context)
        val composeView = ComposeView(context).apply {
            setContent {
                MaterialTheme {
                    TextField(value = text, onValueChange = { updateState(it) })
                }
            }
        }

        addView(textView)
        addView(composeView)
    }

    // Update both the source of truth and the TextView
    private fun updateState(newValue: String) {
        text = newValue
        textView.text = newValue
    }
}

העברת ממשק משתמש משותף

אם אתם עוברים בהדרגה ל-Compose, יכול להיות שתצטרכו להשתמש ברכיבי ממשק משתמש משותפים גם ב-Compose וגם במערכת התצוגה. לדוגמה, אם באפליקציה שלכם יש רכיב CallToActionButton בהתאמה אישית, יכול להיות שתצטרכו להשתמש בו גם במסכים של Compose וגם במסכים מבוססי-תצוגה.

ב-Compose, רכיבי ממשק משתמש משותפים הופכים לרכיבים שאפשר לעשות בהם שימוש חוזר באפליקציה, ללא קשר לסגנון של הרכיב באמצעות XML או לכך שהוא תצוגה מותאמת אישית. לדוגמה, יוצרים רכיב CallToActionButton שמורכב מרכיב Button של קריאה מותאמת אישית לפעולה.

כדי להשתמש ב-composable במסכים שמבוססים על תצוגה, צריך ליצור מעטפת תצוגה בהתאמה אישית שמתרחבת מ-AbstractComposeView. ברכיב הקומפוזיציה Content שבו בוטל השינוי, צריך להציב את הרכיב הקומפוזיציה שיצרתם, עטוף בעיצוב של Compose, כפי שמתואר בדוגמה הבאה:

@Composable
fun CallToActionButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.secondary
        ),
        onClick = onClick,
        modifier = modifier,
    ) {
        Text(text)
    }
}

class CallToActionViewButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {

    var text by mutableStateOf("")
    var onClick by mutableStateOf({})

    @Composable
    override fun Content() {
        YourAppTheme {
            CallToActionButton(text, onClick)
        }
    }
}

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

class ViewBindingActivity : ComponentActivity() {

    private lateinit var binding: ActivityExampleBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityExampleBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.callToAction.apply {
            text = getString(R.string.greeting)
            onClick = { /* Do something */ }
        }
    }
}

אם הרכיב המותאם אישית מכיל מצב שאפשר לשנות, כדאי לעיין במאמר מקור האמת של המצב.

קביעת עדיפות לפיצול המצב מהמצגת

באופן מסורתי, View הוא בעל מצב (stateful). View מנהל שדות שמתארים מה להציג, בנוסף לאופן שבו להציג אותו. כשממירים View ל-Compose, כדאי להפריד את הנתונים שעוברים עיבוד כדי ליצור זרימת נתונים חד-כיוונית, כפי שמוסבר בהרחבה בקטע העלאת המצב.

לדוגמה, ל-View יש מאפיין visibility שמתאר אם הוא גלוי, לא גלוי או לא קיים. זוהי תכונה מובנית של View. קטעי קוד אחרים עשויים לשנות את החשיפה של View, אבל רק View עצמו יודע מהי החשיפה הנוכחית שלו. הלוגיקה של הבטחת החשיפה של View עלולה להיות נוטה לשגיאות, ולרוב היא קשורה ל-View עצמו.

לעומת זאת, ב-Compose קל להציג רכיבים מורכבים שונים לגמרי באמצעות לוגיקה מותנית ב-Kotlin:

@Composable
fun MyComposable(showCautionIcon: Boolean) {
    if (showCautionIcon) {
        CautionIcon(/* ... */)
    }
}

מעצם הגדרתו, CautionIcon לא צריך לדעת למה הוא מוצג או להתייחס לכך, ואין מושג של visibility: הוא נמצא ב-Composition או שהוא לא נמצא בו.

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

שימוש ברכיבים בקופסה (encapsulated) שניתנים לשימוש חוזר

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

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

הבעיה הזו פחות משמעותית ב-Compose עם רכיבים שניתנים לשימוש חוזר. ההורים יכולים לציין בקלות את המצב והקריאות החוזרות (callbacks), כך שתוכלו לכתוב רכיבים מורכבים לשימוש חוזר בלי לדעת את המיקום המדויק שבו הם ישמשו.

@Composable
fun AScreen() {
    var isEnabled by rememberSaveable { mutableStateOf(false) }

    Column {
        ImageWithEnabledOverlay(isEnabled)
        ControlPanelWithToggle(
            isEnabled = isEnabled,
            onEnabledChanged = { isEnabled = it }
        )
    }
}

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

  • ImageWithEnabledOverlay צריך לדעת רק מה המצב הנוכחי של isEnabled. הוא לא צריך לדעת ש-ControlPanelWithToggle קיים, או איך אפשר לשלוט בו.

  • ControlPanelWithToggle לא יודע על קיומו של ImageWithEnabledOverlay. יכול להיות שisEnabled יוצג באפס, באחת או בכמה דרכים, ו-ControlPanelWithToggle לא יצטרך להשתנות.

  • לאב לא משנה כמה רמה עמוקה ImageWithEnabledOverlay או ControlPanelWithToggle נמצאים בהיררכיה. הילדים האלה יכולים ליצור אנימציות של שינויים, להחליף תוכן או להעביר תוכן לילדים אחרים.

התבנית הזו נקראת הפיכת שליטה, וניתן לקרוא מידע נוסף עליה במסמכי העזרה של CompositionLocal.

טיפול בשינויים בגודל המסך

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

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

גלילה בתצוגות עץ באמצעות Views

במאמר יכולת פעולה הדדית של גלילה בתצוגה בתצוגה בתצוגה מוסבר איך מפעילים יכולת פעולה הדדית של גלילה בתצוגה בתצוגה בתצוגה בין רכיבי View שניתנים לגלילה לבין רכיבי composable שניתנים לגלילה, שמוטמעים בשני הכיוונים.

כתיבת הודעות ב-RecyclerView

ב-RecyclerView, רכיבים מורכבים עובדים בצורה טובה מאז גרסת RecyclerView 1.3.0-alpha02. כדי ליהנות מהיתרונות האלה, צריך לוודא שאתם משתמשים בגרסה 1.3.0-alpha02 לפחות של RecyclerView.

WindowInsets יכולת פעולה הדדית עם Views

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

לדוגמה, אם הפריסה החיצונית ביותר היא פריסה של Android View, צריך להשתמש ב-insets במערכת View ולהתעלם מהם ב-Compose. לחלופין, אם הפריסה החיצונית ביותר היא רכיב Compose, צריך להשתמש ברכיבי ה-inset ב-Compose ולספק את הרווח הנדרש לרכיבי ה-Compose של AndroidView בהתאם.

כברירת מחדל, כל ComposeView צורך את כל ה-insets ברמת הצריכה WindowInsetsCompat. כדי לשנות את התנהגות ברירת המחדל הזו, מגדירים את הערך של ComposeView.consumeWindowInsets לערך false.

מידע נוסף זמין במסמכי התיעוד בנושא WindowInsets ב-Compose.