שימוש בדפוסי Kotlin נפוצים ב-Android

במאמר הזה נתמקד בכמה מההיבטים השימושיים ביותר של שפת Kotlin בפיתוח לאנדרואיד.

עבודה עם פרגמנטים

בדוגמאות הבאות של Fragment מודגשות כמה מהתכונות הטובות ביותר של Kotlin.

ירושה

אפשר להגדיר מחלקה ב-Kotlin באמצעות מילת המפתח class. בדוגמה הבאה, LoginFragment הוא מחלקת משנה של Fragment. אפשר לציין ירושה באמצעות האופרטור : בין מחלקת המשנה לבין מחלקת האב שלה:

class LoginFragment : Fragment()

בהצהרה על המחלקה הזו, LoginFragment אחראי לקריאה לבונה של מחלקת העל שלו, Fragment.

בתוך LoginFragment, אפשר לבטל מספר קריאות חוזרות (callback) של מחזור החיים כדי להגיב לשינויים במצב של Fragment. כדי לשנות פונקציה, משתמשים במילת המפתח override, כמו בדוגמה הבאה:

override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
): View? {
    return inflater.inflate(R.layout.login_fragment, container, false)
}

כדי להפנות לפונקציה במחלקת האב, משתמשים במילת המפתח super, כמו בדוגמה הבאה:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
}

אפשרות להקצאת ערך Null ואתחול

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

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

המאפיין lateinit מאפשר לדחות את אתחול הנכס. כשמשתמשים ב-lateinit, צריך לאתחל את הנכס בהקדם האפשרי.

בדוגמה הבאה מוצג שימוש ב-lateinit כדי להקצות אובייקטים מסוג View ב-onViewCreated:

class LoginFragment : Fragment() {

    private lateinit var usernameEditText: EditText
    private lateinit var passwordEditText: EditText
    private lateinit var loginButton: Button
    private lateinit var statusTextView: TextView

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        usernameEditText = view.findViewById(R.id.username_edit_text)
        passwordEditText = view.findViewById(R.id.password_edit_text)
        loginButton = view.findViewById(R.id.login_button)
        statusTextView = view.findViewById(R.id.status_text_view)
    }

    ...
}

המרת SAM

אפשר להאזין לאירועי קליקים ב-Android באמצעות הטמעה של הממשק OnClickListener. אובייקטים מסוג Button מכילים פונקציה setOnClickListener() שמקבלת הטמעה של OnClickListener.

ל-OnClickListener יש שיטה מופשטת אחת, onClick(), שחובה להטמיע. מכיוון שהפונקציה setOnClickListener() תמיד מקבלת את OnClickListener כארגומנט, ומכיוון שלפונקציה OnClickListener תמיד יש את אותה שיטה מופשטת יחידה, אפשר לייצג את ההטמעה הזו באמצעות פונקציה אנונימית ב-Kotlin. התהליך הזה נקרא המרת שיטה מופשטת יחידה או המרת SAM.

המרת SAM יכולה להפוך את הקוד שלכם להרבה יותר נקי. בדוגמה הבאה מוצג אופן השימוש בהמרת SAM להטמעה של OnClickListener עבור Button:

loginButton.setOnClickListener {
    val authSuccessful: Boolean = viewModel.authenticate(
            usernameEditText.text.toString(),
            passwordEditText.text.toString()
    )
    if (authSuccessful) {
        // Navigate to next screen
    } else {
        statusTextView.text = requireContext().getString(R.string.auth_failed)
    }
}

הקוד בתוך הפונקציה האנונימית שמועברת אל setOnClickListener() מופעל כשמשתמש לוחץ על loginButton.

אובייקטים נלווים

אובייקטים נלווים מספקים מנגנון להגדרת משתנים או פונקציות שמקושרים קונספטואלית לסוג, אבל לא קשורים לאובייקט מסוים. אובייקטים נלווים דומים לשימוש במילת המפתח static של Java למשתנים ולשיטות.

בדוגמה הבאה, TAG הוא קבוע String. לא צריך ליצור מופע ייחודי של String לכל מופע של LoginFragment, ולכן כדאי להגדיר אותו באובייקט נלווה:

class LoginFragment : Fragment() {

    ...

    companion object {
        private const val TAG = "LoginFragment"
    }
}

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

הענקת גישה לנכס

כשמאתחלים מאפיינים, יכול להיות שתחזרו על חלק מהדפוסים הנפוצים יותר של Android, כמו גישה ל-ViewModel בתוך Fragment. כדי להימנע מכפילויות מיותרות בקוד, אפשר להשתמש בתחביר של העברת מאפיינים ב-Kotlin.

private val viewModel: LoginViewModel by viewModels()

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

העברת הרשאות לנכס משתמשת בהשתקפות, שמוסיפה תקורה מסוימת לביצועים. היתרון הוא תחביר תמציתי שחוסך זמן פיתוח.

מאפיין המציין אם ערך יכול להיות ריק (nullability)

‫Kotlin מספקת כללים מחמירים לגבי ערכי null, ששומרים על בטיחות הסוגים בכל האפליקציה. ב-Kotlin, הפניות לאובייקטים לא יכולות להכיל ערכי null כברירת מחדל. כדי להקצות ערך null למשתנה, צריך להצהיר על סוג משתנה nullable על ידי הוספת ? לסוף סוג הבסיס.

לדוגמה, הביטוי הבא לא חוקי ב-Kotlin. הערך name הוא מסוג String ואי אפשר להגדיר אותו כ-nullable:

val name: String = null

כדי לאפשר ערך null, צריך להשתמש בסוג String שניתן להגדיר כ-null, String?, כמו בדוגמה הבאה:

val name: String? = null

יכולת פעולה הדדית

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

לעתים קרובות, כשכותבים אפליקציית Android, צריך גם לקרוא לקוד שאינו ב-Kotlin, כי רוב ממשקי ה-API של Android כתובים בשפת התכנות Java.

ההתנהגות של Java ו-Kotlin שונה בעיקר בנושא האפשרות להקצאת ערך null. התחביר של Java פחות מחמיר לגבי ערכי null.

לדוגמה, למחלקה Account יש כמה מאפיינים, כולל מאפיין String בשם name. ב-Java אין את הכללים של Kotlin לגבי אפשרות להקצאת ערך null, אלא היא מסתמכת על הערות אופציונליות לגבי אפשרות להקצאת ערך null כדי להצהיר במפורש אם אפשר להקצות ערך null.

מכיוון ש-Android framework נכתב בעיקר ב-Java, יכול להיות שתיתקלו בתרחיש הזה כשקוראים לממשקי API ללא הערות לגבי אפשרות קבלת ערך null.

סוגי פלטפורמות

אם משתמשים ב-Kotlin כדי להפנות לחבר name לא מוער שמוגדר במחלקה Account של Java, הקומפיילר לא יודע אם String ממופה ל-String או ל-String? ב-Kotlin. הדו-משמעות הזו מיוצגת באמצעות סוג פלטפורמה, String!.

ל-String! אין משמעות מיוחדת עבור מהדר Kotlin. ‫String! יכול לייצג String או String?, והקומפיילר מאפשר להקצות ערך מכל אחד מהסוגים. שימו לב: אם מייצגים את הסוג כ-String ומקצים ערך null, יש סיכון להחזרת NullPointerException.

כדי לפתור את הבעיה הזו, כדאי להשתמש בהערות לגבי אפשרות קבלת ערך null כשכותבים קוד ב-Java. ההערות האלה עוזרות למפתחים של Java וגם של Kotlin.

לדוגמה, כך מוגדרת המחלקה Account ב-Java:

public class Account implements Parcelable {
    public final String name;
    public final String type;
    private final @Nullable String accessId;

    ...
}

אחד מהמשתנים החברים, accessId, מסומן ב-@Nullable, שמציין שהוא יכול להכיל ערך null. ‫Kotlin תתייחס אל accessId כאל String?.

כדי לציין שמשתנה אף פעם לא יכול להיות null, משתמשים בהערה @NonNull:

public class Account implements Parcelable {
    public final @NonNull String name;
    ...
}

בתרחיש הזה, name נחשב ל-String שאי אפשר להגדיר כ-null ב-Kotlin.

הערות לגבי אפשרות קבלת ערך null כלולות בכל ממשקי ה-API החדשים של Android וברבים מממשקי ה-API הקיימים של Android. בספריות Java רבות נוספו הערות לגבי אפשרות קבלת ערך null, כדי לתמוך טוב יותר במפתחי Kotlin ו-Java.

טיפול בערכים ריקים

אם אתם לא בטוחים לגבי סוג Java, כדאי להניח שהוא יכול להיות null. לדוגמה, חבר המחלקה name של המחלקה Account לא מסומן, ולכן צריך להניח שהוא יכול להיות null ‏String?.

אם רוצים לקצץ את name כך שהערך שלו לא יכלול רווחים לבנים מובילים או מסיימים, אפשר להשתמש בפונקציה trim של Kotlin. יש כמה דרכים בטוחות לחתוך סרטון.String? אחת הדרכים היא להשתמש באופרטור של הצהרה על אי-ערך null, !!, כמו בדוגמה הבאה:

val account = Account("name", "type")
val accountName = account.name!!.trim()

האופרטור !! מתייחס לכל מה שמופיע בצד ימין שלו כאל ערך שאינו null, ולכן במקרה הזה, המערכת מתייחסת אל name כאל String שאינו null. אם התוצאה של הביטוי שמשמאל היא null, האפליקציה תזרוק NullPointerException. האופרטור הזה מהיר וקל לשימוש, אבל צריך להשתמש בו במשורה כי הוא עלול להחזיר מקרים של NullPointerException לקוד.

אפשרות בטוחה יותר היא להשתמש באופרטור של קריאה בטוחה, ?., כמו בדוגמה הבאה:

val account = Account("name", "type")
val accountName = account.name?.trim()

אם name הוא לא null, התוצאה של name?.trim() היא ערך שם ללא רווחים מובילים או נגררים. אם name הוא null, התוצאה של name?.trim() היא null. המשמעות היא שהאפליקציה שלכם אף פעם לא יכולה להחזיר NullPointerException כשמריצים את ההצהרה הזו.

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

val account = Account("name", "type")
val accountName = account.name?.trim() ?: "Default name"

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

אפשר גם להשתמש באופרטור Elvis כדי לחזור מפונקציה מוקדם, כמו בדוגמה הבאה:

fun validateAccount(account: Account?) {
    val accountName = account?.name?.trim() ?: "Default name"

    // account cannot be null beyond this point
    account ?: return

    ...
}

שינויים ב-Android API

ממשקי ה-API של Android הופכים לידידותיים יותר ל-Kotlin. ממשקי API רבים ב-Android, כולל AppCompatActivity ו-Fragment, מכילים הערות לגבי אפשרות קבלת ערך null, ולחלק מהקריאות כמו Fragment#getContext יש חלופות שמתאימות יותר ל-Kotlin.

לדוגמה, הגישה אל Context של Fragment היא כמעט תמיד לא null, כי רוב הקריאות שמתבצעות ב-Fragment מתרחשות בזמן ש-Fragment מצורף ל-Activity (מחלקת משנה של Context). עם זאת, Fragment#getContext לא תמיד מחזירה ערך שאינו null, כי יש תרחישים שבהם Fragment לא מצורף ל-Activity. לכן, סוג ההחזרה של Fragment#getContext הוא nullable.

מכיוון ש-Context שמוחזר מ-Fragment#getContext ניתן להגדרה כ-null (ומסומן כ-‎ @Nullable), צריך להתייחס אליו כאל Context? בקוד Kotlin. כלומר, צריך להחיל אחד מהאופרטורים שצוינו קודם על כתובת nullability לפני שניגשים למאפיינים ולפונקציות שלה. במקרים מסוימים, מערכת Android כוללת ממשקי API חלופיים שמספקים את הנוחות הזו. ‫Fragment#requireContext, לדוגמה, מחזירה Context שאינו null וזורקת IllegalStateException אם היא נקראת כש-Context הוא null. כך אפשר להתייחס לתוצאה Context כאל ערך שאינו null בלי להשתמש באופרטורים של קריאה בטוחה או בפתרונות עקיפים.

אתחול נכס

ב-Kotlin, מאפיינים לא מאותחלים כברירת מחדל. צריך לאתחל אותם כשמאותחלת המחלקה שמכילה אותם.

יש כמה דרכים לאתחל מאפיינים. בדוגמה הבאה מוצג אופן האתחול של משתנה index על ידי הקצאת ערך למשתנה בהצהרת המחלקה:

class LoginFragment : Fragment() {
    val index: Int = 12
}

אפשר להגדיר את האתחול הזה גם בבלוק אתחול:

class LoginFragment : Fragment() {
    val index: Int

    init {
        index = 12
    }
}

בדוגמאות שלמעלה, index מאותחל כשנוצר LoginFragment.

עם זאת, יכול להיות שיש לכם מאפיינים שלא ניתן לאתחל במהלך בניית האובייקט. לדוגמה, יכול להיות שתרצו להפנות אל View מתוך Fragment, כלומר, צריך קודם להרחיב את הפריסה. אינפלציה לא מתרחשת כשיוצרים Fragment. במקום זאת, הוא מנופח כשמתקשרים אל Fragment#onCreateView.

אחת הדרכים לטפל בתרחיש הזה היא להגדיר את התצוגה ככזו שאפשר להשאיר ריקה (nullable) ולאתחל אותה בהקדם האפשרי, כמו בדוגמה הבאה:

class LoginFragment : Fragment() {
    private var statusTextView: TextView? = null

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)

            statusTextView = view.findViewById(R.id.status_text_view)
            statusTextView?.setText(R.string.auth_failed)
    }
}

השינוי הזה פועל כמצופה, אבל עכשיו צריך לנהל את האפשרות של הערך View להיות null בכל פעם שמפנים אליו. פתרון טוב יותר הוא להשתמש ב-lateinit לאתחול של View, כמו בדוגמה הבאה:

class LoginFragment : Fragment() {
    private lateinit var statusTextView: TextView

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)

            statusTextView = view.findViewById(R.id.status_text_view)
            statusTextView.setText(R.string.auth_failed)
    }
}

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