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

לדוגמה, הפרמטר האחרון לכל הפריסות, כמו הפונקציה הניתנת ליצירה 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")
}

התחביר הזה נפוץ למדי ב-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

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

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

בנאים ופרוטוקולי DSL בטוחים לסוג

Kotlin מאפשרים ליצור שפות ספציפיות לדומיין (DSL) באמצעות builderים שמתאימים לסוג. שפות 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.