在 Android 中使用常见的 Kotlin 模式

本主题重点介绍在进行 Android 开发时最有用的一些 Kotlin 语言特性。

使用 Fragment

以下各节使用 Fragment 示例来突出显示 Kotlin 的一些最佳特性。

继承

在 Kotlin 中,您可以使用 class 关键字声明类。在下面的示例中,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 时,应尽快初始化您的属性。

以下示例演示了如何使用 lateinitonViewCreated 中分配 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 对象包含一个 setOnClickListener() 函数,该函数接收 OnClickListener 的实现。

OnClickListener 具有一个必须实现的单一抽象方法 onClick()。因为 setOnClickListener() 始终接收 OnClickListener 作为参数,并且因为 OnClickListener 始终具有相同的单一抽象方法,所以这种实现可以使用 Kotlin 中的匿名函数来表示。此过程称为单一抽象方法转换,或 SAM 转换

SAM 转换可以使您的代码简洁得多。以下示例展示了如何使用 SAM 转换来实现 ButtonOnClickListener

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 关键字来定义变量和方法。

在下面的示例中,TAG 是一个 String 常量。您不需要为 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 中是非法的。name 的类型为 String,且不可为空

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 中映射为 String 还是 String?。这种歧义通过平台类型 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 进行修剪(trim),使其值不包含前导或尾随空格,可以使用 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 中进行的大多数调用都发生在 Fragment 附加到 ActivityContext 的子类)时。话虽如此,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,因此请务必尽快初始化您的属性。