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

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

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

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

ביטויי למדא בסוף

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

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

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

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

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

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

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

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

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

כוונות וגופי נשק

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

נבחן דוגמה שמשתמשים בה ב-Compose. כשקוראים ל-composable של הפריסה Row, פונקציית הלמבדה של התוכן מופעלת באופן אוטומטי בתוך 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(
            /*...*/
            /* ...
        )
    }
)

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

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

אם מגדירים data class, אפשר לגשת לנתונים באמצעות destructuring declaration. לדוגמה, נניח שאתם מגדירים מחלקה של 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 Compose משתמש ב-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 מאפשרת ליצור בונים (builders) עם מניעת שגיאות הקלדה באמצעות פונקציות ליטרליות עם מקבל. אם ניקח את הפונקציה הקומפוזבילית Canvas כדוגמה, היא מקבלת כפרמטר פונקציה עם 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)
        }
    }
}

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

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

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

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

// 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 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.