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

Text(text = "Hello, Android!")

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

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

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

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

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

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

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

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

פונקציות lambda בסוף

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

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

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

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

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

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

היקפי הרשאות ונמענים

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

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

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

ביטול המבנה של כיתות נתונים

אם מגדירים class של נתונים, אפשר לגשת בקלות לנתונים באמצעות הצהרה של ניתוח מבנה. לדוגמה, נניח שמגדירים את הכיתה 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 מוגדר כאובייקט יחיד (singleton). המאפיינים MaterialTheme.colors,‏ shapes ו-typography מכילים את הערכים של העיצוב הנוכחי.

בוני DSL ו-DSL בטוחים לסוגים

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

ב-Jetpack Compose נעשה שימוש ב-DSLs ל-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 אפשר להבטיח בוני טיפים בטוחים באמצעות ליטרלים של פונקציות עם מקלט. לדוגמה, אם ניקח את ה-composable‏ 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 ו-DSL ללא שגיאות בטיחות סוג זמין במסמכי התיעוד של Kotlin.

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

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

ב-Jetpack Compose יש ממשקי API שמאפשרים להשתמש ב-coroutines בצורה בטוחה בשכבת ממשק המשתמש. הפונקציה rememberCoroutineScope מחזירה CoroutineScope שבעזרתו אפשר ליצור פונקציות רפיטיביות בטיפולי אירועים ולקרוא לממשקי API להשהיה של Compose. בדוגמה הבאה נעשה שימוש ב-animateScrollTo API של 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
                        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.