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

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

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

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

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

אם אתם משתמשים ברכיב הניווט באפליקציה, תוכלו לקרוא מידע נוסף במאמרים ניווט באמצעות Compose – יכולת פעולה הדדית והעברת נתונים מ-Jetpack Navigation ל-Navigation 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()
    }
}

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

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

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

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

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

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

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

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

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

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

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

‫Compose כמקור מידע אמין

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

לדוגמה, יכול להיות שספריית ניתוח הנתונים תאפשר לכם לפלח את אוכלוסיית המשתמשים על ידי צירוף מטא-נתונים מותאמים אישית (מאפייני משתמש בדוגמה הזו) לכל אירועי ניתוח הנתונים הבאים. כדי להעביר את סוג המשתמש של המשתמש הנוכחי לספריית הניתוח, משתמשים ב-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, מומלץ להוסיף את המצב לאובייקטים מסוג 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 וגם במערכת View. לדוגמה, אם לאפליקציה יש רכיב CallToActionButton מותאם אישית, יכול להיות שתצטרכו להשתמש בו גם במסכים שמבוססים על פיתוח נייטיב וגם במסכים שמבוססים על View.

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

כדי להשתמש ב-composable במסכים שמבוססים על View, צריך ליצור 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 ולעבוד איתה, כמו עם תצוגה רגילה. דוגמה לשימוש ב-View Binding:

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

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

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

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

CautionIcon לא צריך לדעת או להתעניין למה הוא מוצג, ואין מושג של visibility: הוא נמצא בקומפוזיציה או שהוא לא נמצא בה.

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

קידום רכיבים שניתנים לשימוש חוזר ומוגדרים כקפסולה

לרכיבי 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 מציעה לבניית ממשקי משתמש אדפטיביים.

גלילה מקוננת עם רכיבי View

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

כתיבת אימייל ב-RecyclerView

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

WindowInsets תאימות עם Views

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

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

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

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