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

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

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

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

באפליקציות שמבוססות על תצוגה, יש שלוש גרסאות של 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:

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

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

למידע נוסף, ראו עיצוב מערכות בכתיבה והעברת עיצובי XML לכתיבה.

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

בדיקת ממשק המשתמש המשולב של 'כתיבה'/'תצוגות'

אחרי העברת חלקים מהאפליקציה ל-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()
    }
}

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

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

דפוסי ארכיטקטורה של 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 הוא מחזור החיים של היעד, והוא נמחק כשהיעד מוסר מה-backstack. בדוגמה הבאה, כשהמשתמש עובר למסך פרופיל, נוצרת מכונה חדשה של 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 כדי לעדכן את הערך שלו.

@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
    }
}

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

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

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

כדי להשתמש בתוכן הקומפוזבילי במסכים המבוססים על תצוגה, צריך ליצור wrapper של תצוגה מותאמת אישית שמרחיבים מ-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 בהתאמה אישית.

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

@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 קל הרבה יותר לשנות פריסות לגמרי בקוד באמצעות לוגיקה מותנית רגילה. מידע נוסף זמין במאמר שימוש בקטגוריות של גודל חלון.

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

גלילה בתצוגות עץ באמצעות 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.