在 Android 中使用常見的 Kotlin 模式

本主題著重介紹在開發 Android 時 Kotlin 語言最實用的部分。

使用片段

下列各節使用 Fragment 範例來突出 Kotlin 的部分最佳功能。

繼承

您可以使用 class 關鍵字在 Kotlin 中宣告類別。在以下範例中,LoginFragmentFragment 的子類別。您可以在子類別和其父項之間使用 : 運算子,來指出繼承:

class LoginFragment : Fragment()

在這個類別宣告中,LoginFragment 負責呼叫其父類別 Fragment 的建構函式。

LoginFragment 中,您可以覆寫許多生命週期回呼,以回應 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)
}

是否可為空值和初始化

在前例中,已覆寫的方法中部分參數的類型附加了問號 ?。這表示傳遞給這些參數的引數可以是空值。請務必安全處理是否可為空值特性

在 Kotlin 中,您必須在宣告物件時對物件的屬性進行初始化調整。這表示當您取得類別的例項時,您可以立即參照其任何一項可存取的屬性。不過,Fragment 中的 View 物件在呼叫 Fragment#onCreateView 之前尚未加載完畢,因此您需要為 View 把屬性初始化延後的方法。

lateinit 可把屬性初始化延後。使用 lateinit 時,應盡快對屬性進行初始化調整。

以下範例說明如何在 onViewCreated 中使用 lateinit 來指派 View 物件:

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 轉換

您可以實作 OnClickListener 介面,來監聽 Android 中的點擊事件。Button 物件包含實作 OnClickListenersetOnClickListener() 函式。

OnClickListener 設有單抽象方法 onClick()。您必須加以實作該方法。由於 setOnClickListener() 一律使用 OnClickListener 做為引數,且 OnClickListener 一律會使用相同的單抽象方法,所以在實作這個方法時,可以在 Kotlin 中以匿名函式表示。這項程序稱為單抽象方法轉換SAM 轉換

SAM 轉換可以讓程式碼看起來清晰得多。以下範例說明如何使用 SAM 轉換來為 Button 實作 OnClickListener

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)
    }
}

使用者點選 loginButton 時,傳送至 setOnClickListener() 的匿名函式中的程式碼就會執行。

伴生物件

伴生物件提供用於定義變數或函式的機制,而這些變數或函式的概念會與類型連結,但不會與特定物件相關聯。伴生物件類似於對變數和方法使用 Java 的 static 關鍵字。

在以下範例中,TAGString 常數。您不需要為 LoginFragment 的每個執行個體指定 String 的唯一執行個體,因此您應該在夥伴物件中定義該執行個體:

class LoginFragment : Fragment() {

    ...

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

您可在檔案頂層定義 TAG,但這個檔案也可能包含許多在頂層定義的變數、函式和類別。夥伴物件可協助連結變數、函式和類別定義,而不會參照該類別的任何執行個體。

屬性委派

對屬性進行初始化調整時,您可以重複使用一些在 Android 中較常見的模式,例如在 Fragment 中存取 ViewModel。為避免產生重複的程式碼,您可以使用 Kotlin 的「屬性委派」語法。

private val viewModel: LoginViewModel by viewModels()

屬性委派是常見的實作做法,可在整個應用程式中重複使用。Android KTX 為您提供了一些屬性委派。例如,viewModels 會擷取範圍限定在目前 FragmentViewModel

屬性委派使用反射,所以會產生一些效能負擔。這樣的好處是獲得簡潔的語法,節省開發時間。

是否可為空值

Kotlin 採用嚴格的是否可為空值規則,以在整個應用程式內維持類型的安全。在 Kotlin 中,根據預設,物件的參照不可包含空值。如要將空值指派給變數,您必須在基本類型的末端加上 ?,以宣告「可為空值」變數類型。

例如,以下運算式在 Kotlin 中無效。nameString 類型,且不可為空值:

val name: String = null

如要允許空值,您必須使用可為空值的 String 類型 String?,如以下範例所示:

val name: String? = null

互通性

Kotlin 採用的嚴格規則能使程式碼更安全、更簡潔。這些規則降低了讓 NullPointerException 導致應用程式當機的機率。此外,這些規則也會減少需在程式碼中執行的空值檢查次數。

通常,在編寫 Android 應用程式時,您也必須呼叫非 Kotlin 程式碼,因為大部分 Android API 皆以 Java 程式設計語言編寫。

是否可為空值是體現 Java 和 Kotlin 行為差別的主要方面。Java 採用較寬鬆的是否可為空值語法。

例如,Account 類別包含幾個屬性,包括稱為 nameString 屬性。Java 沒有採用 Kotlin 的是否可為空值規則,而是依賴可選的「是否可為空值註解」,來明確宣告您是否可以指派空值。

由於 Android 架構主要以 Java 編寫,所以如果您呼叫的 API 不含是否可為空值註解,就可能會遇到這種情況。

平台類型

如果您使用 Kotlin 來參照在 Java Account 類別中定義的未加註 name 成員,編譯器不會知道 String 對應至 Kotlin 中的 StringString?。這種不確定性是透過「平台類型」String! 表示。

String! 對 Kotlin 編譯器沒有特殊意義。String! 可以表示 StringString?,而編譯器可讓您指派任一類型的值。請注意,如果您將類型表示為 String 並指派空值,系統很可能會擲回 NullPointerException

如要解決這個問題,建議您在 Java 中編寫程式碼時,使用是否可為空值註解。這些註解對 Java 和 Kotlin 開發人員都有用。

例如,以下是在 Java 中定義的 Account 類別:

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

    ...
}

其中一個成員變數 accessId@Nullable 加註,表示其可保留空值。這樣,Kotlin 就會將 accessId 視為 String?

如要表示變數不可為空值,請使用 @NonNull 註解:

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

在這種情況下,name 會在 Kotlin 中視為非空值的 String

所有新 Android API 及許多現有 Android API 都包含是否可為空值註解。許多 Java 程式庫新增了是否可為空值註解,讓 Kotlin 和 Java 開發人員能夠獲得更好的支援。

處理是否可為空值

如果您不能確定 Java 類型,則應將該類型判定為可為空值。例如,Account 類別的 name 成員並未加註,因此請將其視為可為空值的 String

如果您想剪輯 name,讓其值不包含開頭或結尾的空白字元,您可以使用 Kotlin 的 trim 函式。您可以透過幾種方式安全地剪輯 String?。其中一種方式是使用「非空值斷言運算子」!!,如以下範例所示:

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

!! 運算子會將左側的所有資訊視為非空值,因此在這種情況下,您會將 name 視為非空值的 String。如果運算子左側的運算式結果為空值,應用程式就會擲回 NullPointerException。這個運算子快速又簡單,但請謹慎使用,因為它可將 NullPointerException 的執行個體重新導入您的程式碼內。

使用「安全呼叫運算子」 ?. 則更安全,如以下範例所示:

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

使用安全呼叫運算子時,如果 name 不是空值,則 name?.trim() 的結果是名稱值,開頭或結尾不含空白字元。如果 name 為空值,則 name?.trim() 的結果是 null。這意味著,應用程式在執行這個陳述式時,絕對不會擲回 NullPointerException

安全呼叫運算子雖避免了 NullPointerException,但會將空值傳送至下一個陳述式。您可以使用「Elvis 運算子」(?:) 立即處理空值的案件,如以下範例所示:

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

如果 Elvis 運算子左側的運算式結果為空值,則右側的值會指派給 accountName。這個技巧對提供本為空值的預設值很有用。

您也可以使用 Elvis 運算子提前從函式傳回結果,如以下範例所示:

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

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

    ...
}

Android API 變更

Android API 越來越支援 Kotlin。許多 Android 最常見的 API (包括 AppCompatActivityFragment) 都含有是否可為空值註解,而某些呼叫 (例如 Fragment#getContext) 則有更多支援 Kotlin 的替代方案。

例如,存取 FragmentContext 幾乎都是非空值,因為您是在 Fragment 附加至 Activity (Context 的子類別) 時,在 Fragment 中發出大部分呼叫。話雖如此,Fragment#getContext 不一定會傳回非空值,因為在特定情境中,Fragment 並沒有附加至 Activity。因此,Fragment#getContext 的傳回類型可為空值。

由於從 Fragment#getContext 傳回的 Context 可為空值 (且以 @Nullable 加註),所以您必須在 Kotlin 程式碼中將其視為 Context?。這意味著,在存取屬性和函式之前,請先套用上述一個運算子來處理是否可為空值問題。針對其中部分情境,Android 包含提供這種便利的替代性 API。例如,Fragment#requireContext 會傳回非空值的 Context,且在 Context 為空值時,如果發出呼叫,則會擲回 IllegalStateException。這樣一來,您就能將產生的 Context 視為非空值,而不需要使用安全呼叫運算子或變通方案。

屬性初始化

根據預設,系統尚未對 Kotlin 中的屬性進行初始化調整。在對這些屬性的封閉式類別進行初始化調整時,也須對屬性進行初始化調整。

您可以透過幾種方式對屬性進行初始化調整。以下範例說明如何在類別宣告中指派值來對 index 變數進行初始化調整:

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

您也可以在初始化器區塊中定義這個初始化作業:

class LoginFragment : Fragment() {
    val index: Int

    init {
        index = 12
    }
}

在以上範例中,index 已在建構 LoginFragment 時完成初始化調整。

不過,部分屬性可能無法在建構物件時完成初始化調整。例如,您可能想要在 Fragment 中參照 View,意味著必須先加載版面配置。在建構 Fragment 時,版面配置尚未加載,而是在呼叫 Fragment#onCreateView 時加載。

解決這個問題的一種方式,就是將檢視畫面宣告為可為空值,並盡快完成初始化調整,如以下範例所示:

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 的是否可為空值特性時,必須管理這種特性。更好的做法是,使用 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,因此請務必盡快對屬性進行初始化調整。