Kotlin ל-Jetpack פיתוח נייטיב

‫Jetpack פיתוח נייטיב מבוסס על Kotlin. במקרים מסוימים, Kotlin מספקת ניבים מיוחדים שמקלים על כתיבת קוד Compose טוב. אם אתם חושבים בשפת תכנות אחרת ומתרגמים אותה ל-Kotlin בראש, סביר להניח שתפספסו חלק מהיתרונות של Compose, ויהיה לכם קשה להבין קוד Kotlin שנכתב בצורה אידיומטית. כדי להימנע מהבעיות האלה, מומלץ להכיר טוב יותר את הסגנון של Kotlin.

ארגומנטים שמוגדרים כברירת מחדל

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

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

// We don't need to do this in Kotlin!
void drawSquare(int sideLength) { }

void drawSquare(int sideLength, int thickness) { }

void drawSquare(int sideLength, int thickness, Color edgeColor) { }

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

fun drawSquare(
    sideLength: Int,
    thickness: Int = 2,
    edgeColor: Color = Color.Black
) {
}

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

drawSquare(30, 5, Color.Red);

לעומת זאת, הקוד הזה מתעד את עצמו:

drawSquare(sideLength = 30, thickness = 5, edgeColor = Color.Red)

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

Text(text = "Hello, Android!")

לקוד הזה יש את אותה השפעה כמו לקוד הבא, שהוא הרבה יותר מפורט, שבו מוגדרים במפורש יותר פרמטרים של Text:

Text(
    text = "Hello, Android!",
    color = Color.Unspecified,
    fontSize = TextUnit.Unspecified,
    letterSpacing = TextUnit.Unspecified,
    overflow = TextOverflow.Clip
)

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

פונקציות מסדר גבוה וביטויי למבדה

‫Kotlin תומכת בפונקציות מסדר גבוה, כלומר פונקציות שמקבלות פונקציות אחרות כפרמטרים. הגישה הזו היא הבסיס ל-Compose. לדוגמה, הפונקציה הניתנת להרכבה Button מספקת פרמטר lambda ‏onClick. הערך של הפרמטר הזה הוא פונקציה, שהלחצן קורא לה כשהמשתמש לוחץ עליו:

Button(
    // ...
    onClick = myClickFunction
)
// ...

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

Button(
    // ...
    onClick = {
        // do something
        // do something else
    }
) { /* ... */ }

פונקציות למדא מסיימות

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

לדוגמה, הפרמטר האחרון לכל הפריסות, כמו פונקציית Column() הניתנת להרכבה, הוא content, פונקציה שפולטת את רכיבי ממשק המשתמש של הצאצא. נניח שרוצים ליצור עמודה שמכילה שלושה רכיבי טקסט, וצריך להחיל עליהם עיצוב מסוים. הקוד הזה יעבוד, אבל הוא מאוד מסורבל:

Column(
    modifier = Modifier.padding(16.dp),
    content = {
        Text("Some text")
        Text("Some more text")
        Text("Last text")
    }
)

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

Column(modifier = Modifier.padding(16.dp)) {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

לשתי הדוגמאות יש בדיוק את אותה משמעות. הסוגריים המסולסלים מגדירים את ביטוי ה-lambda שמועבר לפרמטר content.

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

Column {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

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

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

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

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

Row {
    Text(
        text = "Hello world",
        // This Text is inside a RowScope so it has access to
        // Alignment.CenterVertically but not to
        // Alignment.CenterHorizontally, which would be available
        // in a ColumnScope.
        modifier = Modifier.align(Alignment.CenterVertically)
    )
}

חלק מממשקי ה-API מקבלים ביטויי למבדה שמופעלים בהיקף המקבל. לפונקציות למדא האלה יש גישה למאפיינים ולפונקציות שמוגדרים במקום אחר, על סמך הצהרת הפרמטר:

Box(
    modifier = Modifier.drawBehind {
        // This method accepts a lambda of type DrawScope.() -> Unit
        // therefore in this lambda we can access properties and functions
        // available from DrawScope, such as the `drawRectangle` function.
        drawRect(
            /*...*/
            /* ...
        )
    }
)

מידע נוסף זמין במאמר בנושא function literals with receiver במסמכי התיעוד של Kotlin.

נכסים מואצלים

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

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

קוד אחר יכול לגשת לנכס באמצעות קוד כמו זה:

val myDC = DelegatingClass()
println("The name property is: " + myDC.name)

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

המאפיינים המועברים האלה שימושיים במיוחד כשעובדים עם מאפיינים שמגובים על ידי מצב:

var showDialog by remember { mutableStateOf(false) }

// Updating the var automatically triggers a state change
showDialog = true

פירוק של מחלקות נתונים

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

data class Person(val name: String, val age: Int)

אם יש לכם אובייקט מהסוג הזה, אתם יכולים לגשת לערכים שלו באמצעות קוד כמו זה:

val mary = Person(name = "Mary", age = 35)

// ...

val (name, age) = mary

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

Row {

    val (image, title, subtitle) = createRefs()

    // The `createRefs` function returns a data object;
    // the first three components are extracted into the
    // image, title, and subtitle variables.

    // ...
}

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

אובייקטים מסוג Singleton

ב-Kotlin קל להצהיר על סינגלטונים, שהם מחלקות שתמיד יש להן מופע אחד בלבד. הסינגלטונים האלה מוצהרים באמצעות מילת המפתח object. בדרך כלל משתמשים באובייקטים כאלה ב-Compose. לדוגמה, MaterialTheme מוגדר כאובייקט יחיד. המאפיינים MaterialTheme.colors, shapes ו-typography מכילים את הערכים של ערכת הנושא הנוכחית.

בוני DSL מאובטחים

ב-Kotlin אפשר ליצור שפות ספציפיות לדומיין (DSL) עם builders בטוחים מבחינת סוגים. שפות DSL מאפשרות ליצור מבני נתונים היררכיים מורכבים בצורה קריאה יותר ונוחה יותר לתחזוקה.

ב-Jetpack פיתוח נייטיב נעשה שימוש ב-DSL עבור חלק מהממשקי API, כמו LazyRow ו-LazyColumn.

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        // Add a single item as a header
        item {
            Text("Message List")
        }

        // Add list of messages
        items(messages) { message ->
            Message(message)
        }
    }
}

‫Kotlin מבטיחה בנאים בטוחים מבחינת סוגים באמצעות פונקציות מילוליות עם מקבל. אם ניקח לדוגמה את Canvas composable, היא מקבלת כפרמטר פונקציה עם DrawScope כמקבל, onDraw: DrawScope.() -> Unit, ומאפשרת לקטע הקוד לקרוא לפונקציות חברות שמוגדרות ב-DrawScope.

Canvas(Modifier.size(120.dp)) {
    // Draw grey background, drawRect function is provided by the receiver
    drawRect(color = Color.Gray)

    // Inset content by 10 pixels on the left/right sides
    // and 12 by the top/bottom
    inset(10.0f, 12.0f) {
        val quadrantSize = size / 2.0f

        // Draw a rectangle within the inset bounds
        drawRect(
            size = quadrantSize,
            color = Color.Red
        )

        rotate(45.0f) {
            drawRect(size = quadrantSize, color = Color.Blue)
        }
    }
}

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

שגרות המשך (coroutines) ב-Kotlin

קורוטינות מציעות תמיכה בתכנות אסינכרוני ברמת השפה ב-Kotlin. קורוטינות יכולות להשהות את ההרצה בלי לחסום את השרשורים. ממשק משתמש רספונסיבי הוא אסינכרוני במהותו, ו-Jetpack Compose פותר את הבעיה הזו על ידי שימוש בקורוטינות ברמת ה-API במקום ב-callback.

‫Jetpack Compose מציע ממשקי API שמאפשרים להשתמש ב-coroutines בצורה בטוחה בשכבת ממשק המשתמש. הפונקציה rememberCoroutineScope מחזירה CoroutineScope שאיתו אפשר ליצור קורוטינות במטפלי אירועים ולקרוא לממשקי API של Compose suspend. בדוגמה הבאה מוצג שימוש ב-API של ScrollState.animateScrollTo

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button(
    // ...
    onClick = {
        // Create a new coroutine that scrolls to the top of the list
        // and call the ViewModel to load data
        composableScope.launch {
            scrollState.animateScrollTo(0) // This is a suspend function
            viewModel.loadData()
        }
    }
) { /* ... */ }

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

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

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button( // ...
    onClick = {
        // Scroll to the top and load data in parallel by creating a new
        // coroutine per independent work to do
        composableScope.launch {
            scrollState.animateScrollTo(0)
        }
        composableScope.launch {
            viewModel.loadData()
        }
    }
) { /* ... */ }

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

@Composable
fun MoveBoxWhereTapped() {
    // Creates an `Animatable` to animate Offset and `remember` it.
    val animatedOffset = remember {
        Animatable(Offset(0f, 0f), Offset.VectorConverter)
    }

    Box(
        // The pointerInput modifier takes a suspend block of code
        Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                // Create a new CoroutineScope to be able to create new
                // coroutines inside a suspend function
                coroutineScope {
                    while (true) {
                        // Wait for the user to tap on the screen
                        val offset = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        // Launch a new coroutine to asynchronously animate to
                        // where the user tapped on the screen
                        launch {
                            // Animate to the pressed position
                            animatedOffset.animateTo(offset)
                        }
                    }
                }
            }
    ) {
        Text("Tap anywhere", Modifier.align(Alignment.Center))
        Box(
            Modifier
                .offset {
                    // Use the animated offset as the offset of this Box
                    IntOffset(
                        animatedOffset.value.x.roundToInt(),
                        animatedOffset.value.y.roundToInt()
                    )
                }
                .size(40.dp)
                .background(Color(0xff3c1361), CircleShape)
        )
    }

מידע נוסף על קורוטינות זמין במדריך בנושא קורוטינות של Kotlin ב-Android.