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
    }
) { /* ... */ }

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

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

מידע נוסף על builders ו-DSL בטוחים לטיפוס זמין במסמכי התיעוד של Kotlin.

שגרות משנה (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()
        }
    }
) { /* ... */ }

כברירת מחדל, קורוטינות מריצות את בלוק הקוד באופן רציף. קורוטינה שפועלת ומפעילה פונקציית השהיה מושהית עד שהפונקציה הזו מחזירה ערך. זה נכון גם אם הפונקציה suspend מעבירה את הביצוע ל-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 and animate
                        // in the same block
                        awaitPointerEventScope {
                            val offset = 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.