在Android开发中采用Kotlin

在 2019 年 Google I/O 大会上,谷歌宣布今后将越来越优先采用 Kotlin 进行 Android 开发。Kotlin 是一种富有表现力且简洁的编程语言,不仅可以减少常见代码错误,还可以轻松集成到现有应用中。如果您想构建 Android 应用,建议您从 Kotlin 开始着手,充分利用一流的 Kotlin 功能。

为什么要优先使用 Kotlin 进行 Android 开发?

下面介绍了开发者喜欢用 Kotlin 编写代码的原因:

Kotlin 主要优势的示意图

  • 富有表现力且简洁:您可以使用更少的代码实现更多的功能。表达自己的想法,少编写样板代码。在使用 Kotlin 的专业开发者中,有 67% 的人反映其工作效率有所提高。
  • 更安全的代码:Kotlin 有许多语言功能,可帮助您避免 null 指针异常等常见编程错误。包含 Kotlin 代码的 Android 应用发生崩溃的可能性降低了 20%。
  • 可互操作:您可以在 Kotlin 代码中调用 Java 代码,或者在 Java 代码中调用 Kotlin 代码。Kotlin 可完全与 Java 编程语言互操作,因此您可以根据需要在项目中添加任意数量的 Kotlin 代码。
  • 结构化并发:Kotlin 协程让异步代码像阻塞代码一样易于使用。协程可大幅简化后台任务管理,例如网络调用、本地数据访问等任务的管理。

Kotlin 优先意味着什么?

在构建新的 Android 开发工具和内容(例如 Jetpack 库、示例、文档和培训内容)时,谷歌会在设计层面考虑到 Kotlin 用户,同时继续支持通过 Java 编程语言使用我们的 API。

学习 Kotlin 编程语言

Kotlin 是世界各地的 Android 开发者广泛使用的一种编程语言。本主题可用作 Kotlin 速成课程,帮助您快速上手。

变量声明

Kotlin 使用两个不同的关键字(即 valvar)来声明变量。

  • val 用于值从不更改的变量。使用 val 声明的变量无法重新赋值。
  • var 用于值可以更改的变量。

在以下示例中,count 是一个 Int 类型的变量,初始赋值为 10

var count: Int = 10

Int 是一种表示整数的类型,是可以用 Kotlin 表示的众多数值类型之一。与其他语言类似,您还可以使用 ByteShortLongFloatDouble,具体取决于您的数值数据。

var 关键字表示可以根据需要为 count 重新赋值。例如,可以将 count 的值从 10 更改为 15

var count: Int = 10
count = 15

不过,有些值不应更改。假设有一个名为 languageNameString。如果希望确保 languageName 的值始终为“Kotlin”,则可以使用 val 关键字声明 languageName

val languageName: String = "Kotlin"

通过这些关键字,您可以明确指出哪些变量的值可以更改。请根据需要加以利用。如果引用的变量必须可重新赋值,则将其声明为 var。否则,请使用 val

类型推断

接着前面的示例来讲,为 languageName 赋予初始值后,Kotlin 编译器可根据所赋值的类型来推断其类型。

由于 "Kotlin" 的值为 String 类型,因此编译器推断 languageName 也为 String。请注意,Kotlin 是一种静态类型的语言。这意味着,类型将在编译时解析且从不改变。

在以下示例中,languageName 推断为 String,因此无法对其调用任何不属于 String 类的函数:

val languageName = "Kotlin"
val upperCaseName = languageName.toUpperCase()

// Fails to compile
languageName.inc()

toUpperCase() 是一个只能对 String 类型的变量调用的函数。由于 Kotlin 编译器已将 languageName 推断为 String,因此可以安全地调用 toUpperCase()。不过,inc() 是一个 Int 运算符函数,因此无法对 String 调用它。利用 Kotlin 的类型推断,既能确保代码简洁,又能确保类型安全。

Null 安全

在某些语言中,可以声明引用类型变量而不明确提供初始值。在这类情况下,变量通常包含 null 值。默认情况下,Kotlin 变量不能持有 null 值。这意味着以下代码段无效:

// Fails to compile
val languageName: String = null

要使变量持有 null 值,它必须是可为 null 类型。可以在变量类型后面加上 ? 后缀,将变量指定为可为 null,如以下示例所示:

val languageName: String? = null

指定 String? 类型后,可以为 languageName 赋予 String 值或 null

必须小心处理可为 null 的变量,否则可能会出现可怕的 NullPointerException。例如,在 Java 中,如果尝试对 null 值调用方法,程序会发生崩溃。

条件语句

Kotlin 提供了几种实现条件逻辑的机制,其中最常见的是 if-else 语句。如果 if 关键字后面括在圆括号内的表达式求值为 true,则会执行该分支中的代码(即,紧跟在后面的括在大括号内的代码)。否则,会执行 else 分支中的代码。

if (count == 42) {
    println("I have the answer.")
} else {
    println("The answer eludes me.")
}

您可以使用 else if 表示多个条件。这样,您就可以在单个条件语句中表示更精细、更复杂的逻辑,如以下示例所示:

if (count == 42) {
    println("I have the answer.")
} else if (count > 35) {
    println("The answer is close.")
} else {
    println("The answer eludes me.")
}

条件语句对于表示有状态的逻辑很有用,但您可能会发现,编写这些语句时会出现重复。在上面的示例中,每个分支都是输出一个 String。为了避免这种重复,Kotlin 提供了条件表达式。最后一个示例可以重新编写如下:

val answerString: String = if (count == 42) {
    "I have the answer."
} else if (count > 35) {
    "The answer is close."
} else {
    "The answer eludes me."
}

println(answerString)

每个条件分支都隐式地返回其最后一行的表达式的结果,因此无需使用 return 关键字。由于全部三个分支的结果都是 String 类型,因此 if-else 表达式的结果也是 String 类型。在本例中,根据 if-else 表达式的结果为 answerString 赋予了一个初始值。利用类型推断可以省略 answerString 的显式类型声明,但为了清楚起见,通常最好添加该声明。

注意:Kotlin 不包含传统的三元运算符,而是倾向于使用条件表达式。

随着条件语句的复杂性不断增加,您可以考虑将 if-else 表达式替换为 when 表达式,如以下示例所示:

val answerString = when {
    count == 42 -> "I have the answer."
    count > 35 -> "The answer is close."
    else -> "The answer eludes me."
}

println(answerString)

when 表达式中每个分支都由一个条件、一个箭头 (->) 和一个结果来表示。如果箭头左侧的条件求值为 true,则会返回右侧的表达式结果。请注意,执行并不是从一个分支跳转到下一个分支。when 表达式示例中的代码在功能上与上一个示例中的代码等效,但可能更易懂。

Kotlin 的条件语句彰显了它的一项更强大的功能,即智能类型转换。您不必使用安全调用运算符或非 null 断言运算符来处理可为 null 的值,而可以使用条件语句来检查变量是否包含对 null 值的引用,如以下示例所示:

val languageName: String? = null
if (languageName != null) {
    // No need to write languageName?.toUpperCase()
    println(languageName.toUpperCase())
}

在条件分支中,languageName 可能会被视为不可为 null。Kotlin 非常智能,能够识别执行分支的条件是 languageName 不持有 null 值,因此您不必在该分支中将 languageName 视为可为 null。这种智能类型转换适用于 null 检查、类型检查,或符合合约的任何条件。

函数

您可以将一个或多个表达式归入一个函数。您可以将相应的表达式封装在一个函数中并调用该函数,而不必在每次需要某个结果时都重复同一系列的表达式。

要声明函数,请使用 fun 关键字,后跟函数名称。接下来,定义函数接受的输入类型(如果有),并声明它返回的输出类型。函数的主体用于定义在调用函数时调用的表达式。

以前面的示例为基础,下面给出了一个完整的 Kotlin 函数:

fun generateAnswerString(): String {
    val answerString = if (count == 42) {
        "I have the answer."
    } else {
        "The answer eludes me"
    }

    return answerString
}

上面示例中的函数名为 generateAnswerString。它不接受任何输入。它会输出 String 类型的结果。要调用函数,请使用函数名称,后跟调用运算符 (())。在下面的示例中,使用 generateAnswerString() 的结果对 answerString 变量进行了初始化。

val answerString = generateAnswerString()

函数可以接受参数输入,如以下示例所示:

fun generateAnswerString(countThreshold: Int): String {
    val answerString = if (count > countThreshold) {
        "I have the answer."
    } else {
        "The answer eludes me."
    }

    return answerString
}

在声明函数时,可以指定任意数量的参数及其类型。在上面的示例中,generateAnswerString() 接受一个名为 countThresholdInt 类型的参数。在函数中,可以使用参数的名称来引用参数。

调用此函数时,必须在函数调用的圆括号内添加一个参数:

val answerString = generateAnswerString(42)

简化函数声明

generateAnswerString() 是一个相当简单的函数。该函数声明一个变量,然后立即返回结果。函数返回单个表达式的结果时,可以通过直接返回函数中包含的 if-else 表达式的结果来跳过声明局部变量,如以下示例所示:

fun generateAnswerString(countThreshold: Int): String {
    return if (count > countThreshold) {
        "I have the answer."
    } else {
        "The answer eludes me."
    }
}

您还可以将 return 关键字替换为赋值运算符:

fun generateAnswerString(countThreshold: Int): String = if (count > countThreshold) {
        "I have the answer"
    } else {
        "The answer eludes me"
    }

匿名函数

并非每个函数都需要一个名称。某些函数通过输入和输出更直接地进行标识。这些函数称为“匿名函数”。您可以保留对某个匿名函数的引用,以便日后使用此引用来调用该匿名函数。与其他引用类型一样,您也可以在应用中传递引用。

val stringLengthFunc: (String) -> Int = { input ->
    input.length
}

与命名函数一样,匿名函数也可以包含任意数量的表达式。 函数的返回值是最终表达式的结果。

在上面的示例中,stringLengthFunc 包含对一个匿名函数的引用,该函数将 String 当作输入,并将输入 String 的长度作为 Int 类型的输出返回。因此,该函数的类型表示为 (String) -> Int。不过,此代码不会调用该函数。要检索该函数的结果,您必须像调用命名函数一样调用该函数。调用 stringLengthFunc 时,必须提供 String,如以下示例所示:

val stringLengthFunc: (String) -> Int = { input ->
    input.length
}

val stringLength: Int = stringLengthFunc("Android")

高阶函数

一个函数可以将另一个函数当作参数。将其他函数用作参数的函数称为“高阶函数”。此模式对组件之间的通信(其方式与在 Java 中使用回调接口相同)很有用。

下面是一个高阶函数的示例:

fun stringMapper(str: String, mapper: (String) -> Int): Int {
    // Invoke function
    return mapper(str)
}

stringMapper() 函数接受一个 String 以及一个函数,该函数根据您传递给它的 String 来推导 Int 值。

要调用 stringMapper(),可以传递一个 String 和一个满足其他输入参数的函数(即,一个将 String 当作输入并输出 Int 的函数),如以下示例所示:

stringMapper("Android", { input ->
    input.length
})

如果匿名函数是在某个函数上定义的最后一个参数,则您可以在用于调用该函数的圆括号之外传递它,如以下示例所示:

stringMapper("Android") { input ->
    input.length
}

在整个 Kotlin 标准库中可以找到很多匿名函数。

到目前为止提到的所有类型都已内置在 Kotlin 编程语言中。如果您希望添加自己的自定义类型,可以使用 class 关键字来定义类,如以下示例所示:

class Car

属性

类使用属性来表示状态。属性是类级变量,可以包含 getter、setter 和后备字段。由于汽车需要轮子来驱动,因此您可以添加 Wheel 对象的列表作为 Car 的属性,如以下示例所示:

class Car {
    val wheels = listOf<Wheel>()
}

请注意,wheels 是一个 public val,这意味着,可以从 Car 类外部访问 wheels,并且不能为其重新赋值。如果要获取 Car 的实例,必须先调用其构造函数。这样,您便可以访问它的任何可访问属性。

val car = Car() // construct a Car
val wheels = car.wheels // retrieve the wheels value from the Car

如果希望自定义轮子,可以定义一个自定义构造函数,用来指定如何初始化类属性:

class Car(val wheels: List<Wheel>)

在以上示例中,类构造函数将 List<Wheel> 当作构造函数参数,并使用该参数来初始化其 wheels 属性。

类函数和封装

类使用函数对行为建模。函数可以修改状态,从而帮助您只公开希望公开的数据。这种访问控制机制属于一个面向对象的更大概念,称为“封装”。

在以下示例中,doorLock 属性对 Car 类外部的一切都不公开。要解锁汽车,必须调用 unlockDoor() 函数并传入有效的“钥匙”,如以下示例所示:

class Car(val wheels: List<Wheel>) {

    private val doorLock: DoorLock = ...

    fun unlockDoor(key: Key): Boolean {
        // Return true if key is valid for door lock, false otherwise
    }
}

如果希望自定义属性的引用方式,则可以提供自定义的 getter 和 setter。例如,如果希望公开属性的 getter 而限制访问其 setter,则可以将该 setter 指定为 private

class Car(val wheels: List<Wheel>) {

    private val doorLock: DoorLock = ...

    var gallonsOfFuelInTank: Int = 15
        private set

    fun unlockDoor(key: Key): Boolean {
        // Return true if key is valid for door lock, false otherwise
    }
}

通过结合使用属性和函数,可以创建能够对所有类型的对象建模的类。

互操作性

Kotlin 最重要的功能之一就是它与 Java 之间流畅的互操作性。由于 Kotlin 代码可编译为 JVM 字节码,因此 Kotlin 代码可直接调用 Java 代码,反之亦然。这意味着,您可以直接从 Kotlin 利用现有的 Java 库。此外,绝大多数 Android API 都是用 Java 编写的,因此可以直接从 Kotlin 调用它们。

在 Android 开发中使用常见的 Kotlin 模式

本主题重点介绍 Kotlin 语言在 Android 开发过程中最有用的一些方面。

使用 Fragment

下文使用 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)
}

可为 null 性和初始化

在前面的示例中,被替换的方法中某些参数的类型以问号 ? 为后缀。这表示为这些参数传递的实际参数可以为 null。请务必安全地处理其可为 null 性

在 Kotlin 中,您必须在声明对象时初始化对象的属性。这意味着,当您获取类的实例时,可以立即引用它的任何可访问属性。不过,在调用 Fragment#onCreateView 之前,Fragment 中的 View 对象尚未准备好进行扩充,所以您需要一种方法来推迟 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)
    }

    ...
}

注意:如果您在初始化属性之前对其进行访问,Kotlin 会抛出 UninitializedPropertyAccessException

SAM 转换

您可以通过实现 OnClickListener 接口来监听 Android 中的点击事件。Button 对象包含一个 setOnClickListener() 函数,该函数接受 OnClickListener 的实现。

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 关键字。

在以下示例中,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

属性委托使用反射,这样会增加一些性能开销。这种代价换来的是简洁的语法,可让您节省开发时间。

可为 null 性

Kotlin 提供了严格的可为 null 性规则,可在您的整个应用中维护类型安全。在 Kotlin 中,默认情况下,对对象的引用不能包含 null 值。如需为变量赋 null 值,必须通过将 ? 添加到基本类型的末尾以声明可为 null 变量类型。

例如,以下表达式在 Kotlin 中是违反规则的。name 的类型为 String,不可为 null:

val name: String = null

如需允许 null 值,必须使用可为 null String 类型 String?,如以下示例所示:

val name: String? = null

互操作性

Kotlin 的严格规则可使代码更安全且更简洁。这些规则可降低会导致应用崩溃的 NullPointerException 出现的几率。此外,它们还可减少您需要在代码中进行的 null 检查的次数。

通常,在编写 Android 应用时,您还必须调用非 Kotlin 代码,因为大多数 Android API 都是用 Java 编程语言编写的。

可为 null 性是 Java 和 Kotlin 在行为上有所不同的一个主要方面。Java 对可为 null 性语法的要求不那么严格。

例如,Account 类具有一些属性,包括一个名为 nameString 属性。Kotlin 制定了与可为 null 性有关的规则,Java 没有制定这样的规则,而是依赖于可选的可为 null 性注释明确声明您是否可以赋予 null 值。

由于 Android 框架主要是用 Java 编写的,因此在调用没有可为 null 性注释的 API 时,您可能会遇到这种情况。

平台类型

如果您使用 Kotlin 引用在 Java Account 类中定义的不带注释的 name 成员,编译器将不知道 String 映射到 Kotlin 中的 String 还是 String?。这种不明确性通过平台类型 String! 表示。

String! 对 Kotlin 编译器而言没有特殊的含义。String! 可以表示 StringString?,编译器可让您赋予任一类型的值。请注意,如果您将类型表示为 String 并赋予 null 值,则系统可能会抛出 NullPointerException

为了解决此问题,每当您用 Java 编写代码时,都应使用可为 null 性注释。这些注释对 Java 和 Kotlin 开发者都有帮助。

例如,下面是在 Java 中定义的 Account 类:

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 在 Kotlin 中被视为不可为 null String

可为 null 性注释包含在所有新增的 Android API 以及许多现有的 Android API 中。许多 Java 库已添加可为 null 性注释,以便为 Kotlin 和 Java 开发者提供更好的支持。

处理可为 null 性

如果您不确定 Java 类型,则应将其视为可为 null。例如,Account 类的 name 成员不带注释,因此您应假定它是一个可为 null String

如果希望修剪 name 以使其值不包含前导或尾随空格,则可以使用 Kotlin 的 trim 函数。您可以通过几种不同的方式安全地修剪 String?。其中一种方式是使用非 null 断言运算符 !!,如以下示例所示:

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

!! 运算符将其左侧的所有内容视为非 null,因此,在本例中,应将 name 视为非 null String。如果它左侧表达式的结果为 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 值传递给下一个语句。您可以使用 Elvis 运算符 (?:) 紧接着处理 null 值的情况,如以下示例所示:

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

如果 Elvis 运算符左侧表达式的结果为 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 变更

Android API 对 Kotlin 的支持力度越来越高。Android 的许多最常见的 API(包括 AppCompatActivityFragment)包含可为 null 性注释,并且某些调用(如 Fragment#getContext)具有更支持 Kotlin 的替代调用。

例如,访问 FragmentContext 几乎总是不为 null,因为您在 Fragment 中进行的大多数调用都是在 Fragment 附加到 ActivityContext 的子类)时发生的。即便如此,Fragment#getContext 并不总是返回非 null 值,因为在某些情况下 Fragment 未附加到 Activity。因此,Fragment#getContext 的返回类型可为 null。

由于从 Fragment#getContext 返回的 Context 可为 null(并且带有 @Nullable 注释),因此您必须在 Kotlin 代码中将其视为 Context?。这意味着,在访问其属性和函数之前,需要应用前面提到的某个运算符来处理可为 null 性问题。对于一些这样的情况,Android 包含可提供这种便利的替代 API。例如,Fragment#requireContext 会返回非 null Context,如果在 Context 将为 null 时调用它,则会抛出 IllegalStateException。这样,您就可以将生成的 Context 视为非 null 值,而无需使用安全调用运算符或其他解决方法。

属性初始化

默认情况下,Kotlin 中的属性并未初始化。当初始化属性的封闭类时,必须初始化属性。

您可以通过几种不同的方式来初始化属性。以下示例展示了如何通过在类声明中为 index 变量赋值初始化该变量:

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

此初始化也可以在初始化式块中进行定义:

class LoginFragment : Fragment() {
    val index: Int

    init {
        index = 12
    }
}

上面的示例中,在构建 LoginFragment 时初始化 index

不过,某些属性可能无法在对象构建期间进行初始化。例如,您可能要从 Fragment 中引用 View,这意味着,必须先扩充布局。构建 Fragment 时不会发生扩充,而是在调用 Fragment#onCreateView 时进行扩充。

应对这种情况的一种方法是将视图声明为可为 null 并尽快对其进行初始化,如以下示例所示:

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 性。更好的解决方案是对 View 初始化使用 lateinit,如以下示例所示:

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,因此请务必尽快初始化属性。

注意:诸如数据绑定之类的视图绑定解决方案可以消除对 findViewById 的手动调用,这些解决方案有助于减少您需要考虑的 null 安全问题数量。

Kotlin 样式指南

本文档完整定义了 Google 针对以 Kotlin 编程语言编写的源代码制定的 Android 编码标准。当且仅当 Kotlin 源文件符合此处所述的规则时,才可将其描述为采用了 Google Android 样式。

与其他编程样式指南一样,所涵盖的问题不仅涉及格式设置的美观问题,而且还涉及其他类型的惯例或编码标准。不过,本文档主要介绍我们普遍遵循的清晰硬性规则,而避免给出无法简明地遵循(无论是通过人工还是工具)的建议。

源文件

所有源文件都必须编码为 UTF-8。

命名

如果源文件只包含一个顶级类,则文件名应为该类的名称(区分大小写)加上 .kt 扩展名。否则,如果源文件包含多个顶级声明,则应选择一个可描述文件内容的名称(采用 PascalCase 大小写形式)并附上 .kt 扩展名。

// MyClass.kt
class MyClass { }
// Bar.kt
class Bar { }
fun Runnable.toBar(): Bar = // …
// Map.kt
fun <T, O> Set<T>.map(func: (T) -> O): List<O> = // …
fun <T, O> List<T>.map(func: (T) -> O): List<O> = // …

特殊字符

空白字符

除了行终止符序列之外,ASCII 水平空格字符 (0x20) 是唯一一种可以出现在源文件中任意位置的空白字符。这意味着:

  • 字符串和字符字面量中的其他所有空白字符都会进行转义。
  • 制表符不用于缩进。

特殊转义序列

对于任何具有特殊转义序列(\b\n\r\t\'\"\\\$)的字符,将使用该序列,而不是相应的 Unicode 转义字符(例如 \u000a)。

非 ASCII 字符

对于其余非 ASCII 字符,要么使用实际的 Unicode 字符(例如 ),要么使用等效的 Unicode 转义字符(例如 \u221e)。具体选择仅取决于哪种字符可使代码更容易阅读和理解。建议不要对任何位置的可打印字符使用 Unicode 转义字符,强烈建议不要在字符串字面量和注释之外使用 Unicode 转义字符。

示例 说明
val unitAbbrev = "μs" 最好:即使没有注释,也非常清楚。
val unitAbbrev = "\u03bcs" // μs 差:没有理由对可打印字符使用转义。
val unitAbbrev = "\u03bcs"` 差:读者不知道这是什么。
return "\ufeff" + content 好:对不可打印字符使用转义,并在必要时添加注释。

结构

.kt 文件由下面几部分组成(按顺序列出):

  • 版权和/或许可标头(可选)
  • 文件级注释
  • package 语句
  • import 语句
  • 顶级声明

上述各部分用一个空白行隔开。

版权/许可

如果文件中包含版权或许可标头,应将其放在多行注释的最上方。

/*
 * Copyright 2017 Google, Inc.
 *
 * ...
 */
 

请勿使用 KDoc 样式或单行样式的注释。

/**
 * Copyright 2017 Google, Inc.
 *
 * ...
 */
// Copyright 2017 Google, Inc.
//
// ...

文件级注释

应将具有“file”使用处目标的注释放在任何标头文件注释和软件包声明之间。

package 语句

package 语句不受任何列限制且从不换行。

import 语句

应将类、函数和属性的 import 语句归在单个列表中并按 ASCII 进行排序。

不允许(任何类型的)通配符导入。

与 package 语句类似,import 语句也不受列限制且从不换行。

顶级声明

.kt 文件可以在顶级声明一个或多个类型、函数、属性或类型别名。

文件的内容应集中在单个主题上。例如,单个公共类型或对多个接收器类型执行同一操作的一组扩展函数。应将不相关的声明分离到它们自己的文件中,并最大限度地减少单个文件中的公共声明。

对文件的内容量和内容顺序没有做出明确的限制。

通常按从上到下的顺序读取源文件,这意味着,顺序通常应反映出位置比较靠上的声明将有助于理解位置比较靠下的声明。不同的文件可能会选择以不同的方式对内容进行排序。同样,一个文件可能包含 100 个属性,另一个文件可能包含 10 个函数,还有一个文件可能只包含一个类。

重要的是,每个类都采用某种逻辑顺序,类的维护人员在被问及时应可以解释清楚相应逻辑顺序。例如,新函数不应直接习惯性地添加到类的末尾,因为这样会产生“按添加日期先后顺序”排序,而这不是逻辑排序。

类成员排序

类中成员的顺序遵循的规则与顶级声明相同。

格式设置

大括号

when 分支不需要大括号,没有 else if/else 分支且适合放在一行的 if 语句主体也不需要大括号。

if (string.isEmpty()) return

when (value) {
    0 -> return
    // …
}

除此以外,任何 ifforwhen 分支、dowhile 语句都需要大括号,即使主体为空或仅包含一个语句也是如此。

if (string.isEmpty())
    return  // WRONG!

if (string.isEmpty()) {
    return  // Okay
}

非空块

对于非空块和类似块的构造,大括号遵循 Kernighan 和 Ritchie (K&R) 样式(“埃及括号”):

  • 左大括号前面没有换行符。
  • 左大括号后面有换行符。
  • 右大括号前面有换行符。
  • 仅当右大括号终止语句或者终止函数、构造函数或命名类的主体时,它后面才有换行符。例如,如果右大括号后跟 else 或一个逗号,则它后面没有换行符。
return Runnable {
    while (condition()) {
        foo()
    }
}

return object : MyClass() {
    override fun foo() {
        if (condition()) {
            try {
                something()
            } catch (e: ProblemException) {
                recover()
            }
        } else if (otherCondition()) {
            somethingElse()
        } else {
            lastThing()
        }
    }
}

下面给出了枚举类的一些例外情况。

空块

空块或类似块的构造必须采用 K&R 样式。

try {
    doSomething()
} catch (e: Exception) {} // WRONG!
try {
    doSomething()
} catch (e: Exception) {
} // Okay

表达式

仅当整个表达式适合放在一行时,用作表达式的 if/else 条件语句才能省略大括号。

val value = if (string.isEmpty()) 0 else 1  // Okay
val value = if (string.isEmpty())  // WRONG!
    0
else
    1
val value = if (string.isEmpty()) { // Okay
    0
} else {
    1
}

缩进

每个新块或类似块的构造开始时,缩进都会增加四个空格。当块结束时,缩进会恢复到上一个缩进级别。缩进级别适用于整个块中的代码和注释。

每行一个语句

每个语句都后跟一个换行符。不使用分号。

换行

代码的列限制为最多 100 个字符。除非是下面说明的情况,否则任何超过此限制的行都必须换行,如下所述。

例外情况:

  • 无法遵循列限制的行(例如,KDoc 中的长网址)
  • packageimport 语句
  • 注释中可以剪切并粘贴到 shell 中的命令行

在何处换行

换行的首要原则是:更倾向于在较高的句法级别换行。此外:

  • 某行在运算符或 infix 函数名称处换行时,换行符将在该运算符或 infix 函数名称后面。
  • 某行在以下“类似运算符”的符号处换行时,换行符将在该符号前面:
    • 点分隔符(.?.)。
    • 成员引用的两个冒号 (::)。
  • 方法或构造函数名称始终贴在它后面的左圆括号 (() 上。
  • 逗号 (,) 始终贴在它前面的标记上。
  • lambda 箭头 (->) 始终贴在它前面的参数列表上。

注意:换行的主要目标是让代码清晰,而不一定是让代码适合放在最少数量的行中。

函数

当函数签名不适合放在一行上时,应让每个参数声明独占一行。以这种格式定义的参数应使用单缩进 (+4)。右圆括号 ()) 和返回类型独占一行,没有额外的缩进。

fun <T> Iterable<T>.joinToString(
    separator: CharSequence = ", ",
    prefix: CharSequence = "",
    postfix: CharSequence = ""
): String {
    // …
}

表达式函数

当函数只包含一个表达式时,它可以表示为表达式函数

override fun toString(): String {
    return "Hey"
}
override fun toString(): String = "Hey"

只有在表达式函数开始一个块时,才应换行。

fun main() = runBlocking {
  // …
}

否则,如果表达式函数增长到需要换行,应改用普通函数主体、return 声明和普通表达式换行规则。

属性

当属性初始化式不适合放在一行时,应在等号 (=) 后面换行,并使用缩进。

private val defaultCharset: Charset? =
        EncodingRegistry.getInstance().getDefaultCharsetForPropertiesFiles(file)

声明 get 和/或 set 函数的属性应让每个函数独占一行,并使用正常的缩进 (+4)。对它们进行格式设置时,使用的规则与函数相同。

var directory: File? = null
    set(value) {
        // …
    }

只读属性可以使用适合放在一行的较短语法。

val defaultExtension: String get() = "kt"

空白

垂直

一个空白行会:

  • 出现在类的连续成员(属性、构造函数、函数、嵌套类等)之间。
    • 例外情况:两个连续属性(它们之间没有其他代码)之间的空白行是可选的。根据需要使用此类空白行来创建属性的逻辑分组,并将属性与其后备属性(如果存在)相关联。
    • 例外情况:下文对枚举常量之间的空白行进行了介绍。
  • 根据需要出现在语句之间,用于将代码划分为一些逻辑子部分。
  • (可选)出现在函数中的第一个语句前面、类的第一个成员前面,或类的最后一个成员后面(既不鼓励也不反对)。
  • 根据本文档中其他部分(如结构部分)的要求出现。

允许出现多个连续的空白行,但不鼓励也从不要求必须采用这种样式。

水平

在语言或其他样式规则要求的范围之外,除了字面量、注释和 KDoc,还会出现一个 ASCII 空格,它只会出现在以下位置:

  • 将任何保留字(如 ifforcatch)与该行中在它后面的左圆括号 (() 隔开。
    // WRONG!
    for(i in 0..1) {
    }
    
    // Okay
    for (i in 0..1) {
    }
    
  • 将任何保留字(如 elsecatch)与该行中在它前面的右大括号 (}) 隔开。
    // WRONG!
    }else {
    }
    
    // Okay
    } else {
    }
    
  • 在任何左大括号 ({ ) 前面。
    // WRONG!
    if (list.isEmpty()){
    }
    
    // Okay
    if (list.isEmpty()) {
    }
    
  • 在任何二元运算符的两边。
    // WRONG!
    val two = 1+1
    
    // Okay
    val two = 1 + 1
    
    这也适用于以下“类似运算符”的符号:
    • lambda 表达式中的箭头 (->)。
      // WRONG!
      ints.map { value->value.toString() }
      
      // Okay
      ints.map { value -> value.toString() }
      
    但不适用于以下符号:
    • 成员引用的两个冒号 (::)。
      // WRONG!
      val toString = Any :: toString
      
      // Okay
      val toString = Any::toString
      
    • 点分隔符 (.)。
      // WRONG
      it . toString()
      
      // Okay
      it.toString()
      
    • 范围运算符 (..)。
      // WRONG
       for (i in 1 .. 4) print(i)
       
       // Okay
       for (i in 1..4) print(i)
      
  • 仅当在用于指定基类或接口的类声明中使用,或在泛型约束where 子句中使用时,才会出现在冒号 (:) 前面。
    // WRONG!
    class Foo: Runnable
    
    // Okay
    class Foo : Runnable
    
    // WRONG
    fun <T: Comparable> max(a: T, b: T)
    
    // Okay
    fun <T : Comparable> max(a: T, b: T)
    
    // WRONG
    fun <T> max(a: T, b: T) where T: Comparable<T>
    
    // Okay
    fun <T> max(a: T, b: T) where T : Comparable<T>
    
  • 在逗号 (,) 或冒号 (:) 后面。
    // WRONG!
    val oneAndTwo = listOf(1,2)
    
    // Okay
    val oneAndTwo = listOf(1, 2)
    
    // WRONG!
    class Foo :Runnable
    
    // Okay
    class Foo : Runnable
    
  • 在开始行尾注释的双正斜杠 (//) 的两边。在此处,允许出现多个空格,但不要求必须采用这种样式。
    // WRONG!
    var debugging = false//disabled by default
    
    // Okay
    var debugging = false // disabled by default
    

绝不能将此规则解释为要求或禁止在行的开头或末尾添加额外的空格,它仅规定内部空格。

特定构造

枚举类

对于没有函数且没有关于其常量的文档的枚举,可以选择性地将其格式设为单行。

enum class Answer { YES, NO, MAYBE }

将枚举中的常量放在单独的行上时,它们之间不需要空白行,但它们定义主体时除外。

enum class Answer {
    YES,
    NO,

    MAYBE {
        override fun toString() = """¯\_(ツ)_/¯"""
    }
}

由于枚举类是类,因此用于类格式设置的其他所有规则都适用。

注释

应将成员或类型注释放在单独的行上,让其紧接在标注的构造前面。

@Retention(SOURCE)
@Target(FUNCTION, PROPERTY_SETTER, FIELD)
annotation class Global

可以将不带参数的注释放在一行上。

@JvmField @Volatile
var disposable: Disposable? = null

如果只存在一个不带参数的注释,可以将其与声明放在同一行上。

@Volatile var disposable: Disposable? = null

@Test fun selectAll() {
    // …
}

@[...] 语法只能用于明确的使用处目标,并且只能用于将没有参数的两个或更多注释组合在一行中。

@field:[JvmStatic Volatile]
var disposable: Disposable? = null

隐式返回/属性类型

如果表达式函数主体或属性初始化式是标量值,或者可以根据主体明确推断出返回类型,则可以将其省略。

override fun toString(): String = "Hey"
// becomes
override fun toString() = "Hey"
private val ICON: Icon = IconLoader.getIcon("/icons/kotlin.png")
// becomes
private val ICON = IconLoader.getIcon("/icons/kotlin.png")

在编写库时,如果显式类型声明是公共 API 的一部分,则应将其保留。

命名

标识符仅使用 ASCII 字母和数字,在下面所述的少数情况下,还会使用下划线。因此,每个有效的标识符名称都可匹配正则表达式 \w+

不使用特殊前缀或后缀(例如 name_mNames_namekName 示例中的前缀或后缀),但后备属性除外(请参阅后备属性)。

软件包名称

软件包名称全部为小写字母,连续的单词直接连接在一起(没有下划线)。

// Okay
package com.example.deepspace
// WRONG!
package com.example.deepSpace
// WRONG!
package com.example.deep_space

类型名称

类名采用 PascalCase 大小写形式编写,通常是名词或名词短语。例如,CharacterImmutableList。接口名称也可以是名词或名词短语(例如 List),但有时还可以是形容词或形容词短语(例如 Readable)。

测试类的命名方式是以测试的类的名称开头且以 Test 结尾。例如,HashTestHashIntegrationTest

函数名称

函数名称采用 camelCase 大小写形式编写,通常是动词或动词短语。例如,sendMessagestop

允许在测试函数名称中出现下划线,用于分隔名称的逻辑组成部分。

@Test fun pop_emptyStack() {
    // …
}

带有 @Composable 注释且返回 Unit 的函数都采用 Pascal 大小写形式并以名词形式命名,就像它们是某种类型。

@Composable
fun NameTag(name: String) {
    // …
}

常量名称

常量名称使用 UPPER_SNAKE_CASE 大小写形式:全部为大写字母,单词用下划线分隔。但究竟什么是常量?

常量是没有自定义 get 函数的 val 属性,其内容绝对不可变,并且其函数没有可检测到的副作用。这包括不可变类型和不可变类型的不可变集合,以及标量和字符串(如果标记为 const)。如果某个实例的任何可观察状态可以改变,则它不是常量。仅仅计划永不改变对象是不够的。

const val NUMBER = 5
val NAMES = listOf("Alice", "Bob")
val AGES = mapOf("Alice" to 35, "Bob" to 32)
val COMMA_JOINER = Joiner.on(',') // Joiner is immutable
val EMPTY_ARRAY = arrayOf()

这些名称通常是名词或名词短语。

常量值只能在 object 内定义或定义为顶级声明。满足常量的要求但是在 class 内定义的值必须使用非常量名称。

作为标量值的常量必须使用 const 修饰符

非常量名称

非常量名称采用 camelCase 大小写形式编写。这些适用于实例属性、本地属性和参数名称。

val variable = "var"
val nonConstScalar = "non-const"
val mutableCollection: MutableSet = HashSet()
val mutableElements = listOf(mutableInstance)
val mutableValues = mapOf("Alice" to mutableInstance, "Bob" to mutableInstance2)
val logger = Logger.getLogger(MyClass::class.java.name)
val nonEmptyArray = arrayOf("these", "can", "change")

这些名称通常是名词或名词短语。

后备属性

需要后备属性时,其名称应与实际属性的名称完全匹配,只不过带有下划线前缀。

private var _table: Map? = null

val table: Map
    get() {
        if (_table == null) {
            _table = HashMap()
        }
        return _table ?: throw AssertionError()
    }

类型变量名称

可以采用以下两种样式之一对每个类型变量命名:

  • 一个大写字母,可选择在其后跟一个数字(例如 ETXT2
  • 用于类的形式的名称,后跟大写字母 T(如 RequestTFooBarT

驼峰式大小写

有时,有多种合理的方法可将英语短语转换为驼峰式大小写,例如,当存在首字母缩写词或者诸如“IPv6”或“iOS”之类不寻常的构造时。要提高可预测性,请使用以下方案。

从名称的普通形式入手:

  1. 将短语转换为纯 ASCII 并移除所有撇号。例如,“Müller’s algorithm”可能会变为“Muellers algorithm”。
  2. 将此结果分成单词,在空格和其余所有标点符号(通常是连字符)处分割。建议:如果任何单词在常见用法中已有惯用的驼峰式大小写外观,请将其分成各个组成部分(例如,“AdWords”将变为“ad words”)。请注意,诸如“iOS”之类的单词实际上本身并未采用驼峰式大小写;它违反了任何惯例,所以此建议不适用。
  3. 现在,将所有内容(包括首字母缩写词)小写,然后执行以下某项操作:
    • 将每个单词的第一个字符大写,以生成 Pascal 大小写。
    • 将除第一个单词之外的每个单词的第一个字符大写,以生成驼峰式大小写。
  4. 最后,将所有单词连接成一个标识符。

请注意,原始单词的大小写几乎完全被忽略。

普通形式 正确 不正确
“XML Http Request” XmlHttpRequest XMLHTTPRequest
“new customer ID” newCustomerId newCustomerID
“inner stopwatch” innerStopwatch innerStopWatch
“supports IPv6 on iOS” supportsIpv6OnIos supportsIPv6OnIOS
“YouTube importer” YouTubeImporter YoutubeImporter*

(* 可接受,但不建议。)

注意:在英语中,有些单词并未明确规定是否需要使用连字符连接。例如,“nonempty”和“non-empty”都正确,所以方法名称 checkNonemptycheckNonEmpty 同样也都正确。

文档

格式设置

要了解 KDoc 块的基本格式设置,请查看以下示例:

/**
 * Multiple lines of KDoc text are written here,
 * wrapped normally…
 */
fun method(arg: String) {
    // …
}

或查看以下单行示例:

/** An especially short bit of KDoc. */

基本形式始终可接受。当整个 KDoc 块(包括注释标记)适合放在一行时,就可以替换为单行形式。请注意,仅当没有块标记(如 @return)时,此规则才适用。

段落

一个空白行(即,仅包含对齐的前导星号 (*) 的行)会出现在段落之间,以及成组的块标记(如果存在)前面。

块标记

使用的任何标准“块标记”都按 @constructor@receiver@param@property@return@throws@see 的顺序出现,并且这些块标记从不与空说明一起出现。当块标记不适合放在一行时,连续行会从 @ 的位置开始缩进 4 个空格。

摘要片段

每个 KDoc 块都以一个简短的摘要片段开头。此片段非常重要:它是唯一一个会出现在某些上下文(例如类和方法索引)中的文本部分。

这是一个片段(名词短语或动词短语),而不是一个完整的句子。它不以“A `Foo` is a...”或“This method returns...”开头,也不必形成一个完整的祈使句(如“Save the record.”)。不过,此片段就像一个完整的句子一样首字母大写并加标点符号。

用法

至少每个 public 类型以及这样的类型的每个 publicprotected 成员都应存在 KDoc,下面列出了一些例外情况。

例外情况:不言自明的函数

对于像 getFoo 这样“简单明了”的函数以及像 foo 这样“简单明了”的属性,KDoc 是可选的。这是因为,对于这些函数和属性,除了“Returns the foo”,真的没有什么需要说明的。

不能用这一例外情况证明省略一般读者可能需要知道的相关信息的合理性,这种做法不当。例如,对于名为 getCanonicalName 的函数或名为 canonicalName 的属性,请勿省略其文档(理由是它只写着 /** Returns the canonical name. */),因为一般读者可能不知道“canonical name”一词是什么意思!

例外情况:替换

替换超类型方法的方法并不总是存在 KDoc。

猜你喜欢

转载自blog.csdn.net/MYBOYER/article/details/109448982