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

Text(text = "Hello, Android!")

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

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

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

פונקציות בסדר עדיפויות גבוה יותר וביטויי Lambda

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

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

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

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

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

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

בוני DSLs ובוני טיפוסים בטוחים

‫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 בטוחים לטיפוסים באמצעות פונקציות מילוליות עם מקבל. אם ניקח את ה-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)
        }
    }
}

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

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

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

‫Jetpack Compose מציע ממשקי API שמאפשרים להשתמש בשגרות משנה בצורה בטוחה בשכבת ממשק המשתמש. הפונקציה rememberCoroutineScope מחזירה CoroutineScope שאיתו אפשר ליצור שגרות משנה (coroutines) ב-event handlers ולקרוא לממשקי 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()
        }
    }
) { /* ... */ }

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