持续更新的Android & Android Jetpack & Material Design & Material Design Component(MDC)入门学习笔记

这是我第一次写有关Android的文章,甚至是第一篇有关编程的文章,所以我打算将这个文章作为持续更新的文章,算是学习笔记吧。希望我的文章能给屏幕前的你有所帮助。你可以选择耐心的全部看完,或者点击目录看你想看到的章节。无论是哪种,各位可以选择收藏这篇文章,我应该会持续更新的。
最近搞了一个个人博客,这篇文章的更新也会放在上面,但是是分开章节更新,欢迎关注。

目录

写在前面

其实我接触Android已经3年了,但是每次做新的项目的时候总是要回顾我最开始编写的程序,这说明我在最初的程序里做的好多东西都是常用的,通用性很高,这也是我想写这篇文章的目的之一:将这些东西都总结一下。
当然还有其他的目的。自从Google宣布Android Jetpack之后,有关Android和Material Design Component(MDC)的中文资料太少,好多东西你只能通过Stack Overflow或者官方英文文档才能搜到。前者里面你搜到的问题可能和你的问题不符,后者有的时候非常鸡肋(例如Navigation控件的例子)看得头疼就想看他源码怎么写的,但是找源码看还费时费力。因此,我想写这篇文章总结一下我这几年遇到过的问题和解决方案,下次遇到同样问题的时候不是去看我自己曾经写过的代码,而是看这篇文章就能找到解决问题的办法。
虽然上面有了目录,但是在这里我还是像列举一下我这篇文章的结构,以及我接下来要补充的内容。

  • 写代码得有个好习惯
  • 帮你节省时间的持续集成:Travis CIGitHub Action
  • 从最基础的布局开始:ConstraintLayout 1.0
    • 重点介绍对象
    • 这是有史以来第一次Android实现了拖动放置模块(其实以前也行,但是拖进去之后要写一堆margin和padding,或者LinearLayout堆叠)
    • 2.0以及MotionLayout我还没有研究,等之后吧
  • 必须使用的绑定组件工具:ViewBinding
    • 重点介绍对象
    • ViewBinding很好用,每个写Android程序的人都要使用一下
  • 全新的可持续化数据方式:DataStore
    • 重点介绍对象
    • 需要知道的前置知识:Kotlin Coroutine与Flow
    • 2020年12月我重点攻克对象
    • 重点说说Preferences,Protobuf目前用不到,到时候再说
  • 最强大的列表控件:RecyclerView
    • 重点介绍对象
    • DiffUtil很好用,每个用Recycler且要频繁更新数据的人都要试试
  • 现代化的设计模式:MVVM模式以及ViewModelLiveData
    • 正在探索中
  • 简单易用的ORM数据库包:Room
  • 全盘Fragment化的基础:Navigation导航组件
    • 配合Material自带的过渡动画(Transition)
  • 创建属于自己个性化的应用:Style样式以及Theme主题的自定义
  • 一些其他交互组件的集合
    • 该模块主要是一些大家比较常用的Material Design View的集合,包括ButtonBottomAppBarSnackbar等等
    • 首发记录BottomAppBarSnackbar以及FloatingActionButton之间的配合关系
  • 完全不一样的创建Android工程的方式:Android ComposeFlutter搁置中,咕咕咕,自己现在尝试Desktop版的Compose,太不完善了

本篇文章都是使用Kotlin和Android Studio进行编写。

相关资料

当然我这篇文章只是展现了一部分内容,因此我在这里放出一些相关资料,供大家参考。

等等,Java?Kotlin?

什么?你还在使用Java编写Android程序?不知道Google已经宣布Kotlin是官方语言了吗?
好吧,还是有一些人仍然在执着于Java。现在列举一些Kotlin的好处。

  • Kotlin代码好看一些
  • 大多数变量或常量都可以通过推断来知道变量或常量类型
  • 函数式编程
  • 空指针安全
  • Kotlin的向下支持可以使你用JDK 1.6就能享受到Java 9以上的语言特性
  • 100%兼容Java

所以,还是用Kotlin吧。
还有,如果你使用Android Studio进行编写,那么你应该知道整个工程都使用Gradle进行项目构建。Gradle的配置文件bulid.gradle是用的Groovy编写的,相比于Kotlin它的可读性很差,而且没有IDE上的类型推断支持。得益于Kotlin DSL的支持,你可以将文件重命名为build.gralde.kts,然后将里面的代码全部改写成Kotlin,就好看多了。
在此我放一个模板:

// build.gradle.kts
buildscript {
    repositories {
	// maven仓库用法:maven(url)
        mavenCentral()
        jcenter()
        google()
    }
    dependencies {
        classpath("com.android.tools.build:gradle:4.2.0-alpha07")
        classpath(kotlin("gradle-plugin", version = "1.4.10"))
    }
}

allprojects {
    repositories {
        mavenCentral()
        jcenter()
        google()
    }
}

tasks.register("clean", Delete::class) {
    delete(rootProject.buildDir)
}
// app/build.gradle.kts
// 你还可以定义val或者var
plugins {
    id("com.android.application")
    kotlin("android")
    kotlin("kapt")
    kotlin("plugin.serialization") version "1.4.0"
}
android {
    compileSdkVersion(30)
    defaultConfig {
        applicationId = "com.example.example"
        minSdkVersion(26)
        targetSdkVersion(30)
        versionCode = 1
        versionName = "1.0.0-alpha1"
    }
    buildTypes {
        getByName("debug") {
            isMinifyEnabled = false
        }
        getByName("release") {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
            signingConfig = signingConfigs.findByName("github")
        }
    }
    // 这样可以在src/mian里面使用kotlin文件夹啦~
    sourceSets["main"].java.srcDir("src/main/kotlin")
    // 使用Java 8
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    kotlinOptions.jvmTarget = "1.8"
    // ViewBinding
    buildFeatures.viewBinding = true
}

dependencies {
// settings.gradle.kts
include(":app")

写代码得有个好习惯

如果读者是Android新手的话,建议养成一些好习惯。

  • 不要看到一个好的包就要直接引入,除非它对你写代码有很大的帮助。
    – 我自己写的时候就是好多个包都引入,比如Glide、RxJava、Retrofit等等,后来发现用Okhttp就可以完成的东西就没必要用一堆无用的包了。
    – 多引入包有个坏处,你需要全部都下载下来。在国内如果不用阿里云等镜像,这个速度是相当慢的。
  • 建议使用稳定版的Android Studio,不要使用Eclipse,后者的Android官方插件早就不支持了。而且一定是稳定版的Android Studio,虽然没有新功能(例如4.2可以使用Android Compose)但是更加稳定。你绝对不想编得好好的程序因为IDE的问题导致运行不了。
  • 虽说前两条是不建议的,但是你可以有个工程专门探索新功能,或者是多下一个IDE。
  • 版本号一定要有规律,这个是有一个准则的,我贴在这里,还有中文版
    – 简单来说,版本号的第一个数字是大版本更新,第二个数字是小版本更新,第三个数字是正式版的bug小修。
    – 对于测试版,可以在正式的版本号后面增加alpha、beta、rc,alpha是早期版本,beta是中期版本,rc(Release Candidate)是临近发布的时候的版本
  • 建议自己建一个BaseActivity和BaseFragment,让你所有的Activity和Fragment都继承自你自己创建的类。相信我,你会回来感谢我的。

看完这些,想必你已经按耐住不住想动手试试的感觉了,那现在开始吧~

帮你节省时间的持续集成:Travis CI与GitHub Action

谁说我不能说一些无关Android的东西的呢:>
要知道,开发Android应用最麻烦的地方就是生成带签名的包。每次都要通过Android Studio生成有点太麻烦。于是,你可以使用持续集成(Continuous integration)来帮助你完成这个步骤,只需要给他一个配置信息和一个Keystore,它就可以在你规定好的时间进行build,并且生成一个带有签名的APK包。

Travis CI

在GitHub没有被微软收购之前,有多家网站都提供CI服务,其中Travis CI是集成GitHub最好的一个,所以我最开始也使用的Travis CI进行打包和build。
在你的工程中新建一个名为.travis.yml,并且在Travis CI 网站1或者Travis CI 网站2中登录你的GitHub账号,打开你想使用CI的项目即可。它可免费支持公开项目的CI。你可以在这里看到他的官方文档,但是他们好像不怎么更新了,最新的好像还是API 25的版本,但是你可以使用API30的SDK。
他还可以加密文件,不过得需要Linux环境,安装Ruby之后使用gem install travis就可以使用Travis提供的Travis CLI了。几个比较常用的命令是travis logintravis deploytravis encrypt-file等等。 各位可以自己尝试一下,我自从部署好了之后就忘记怎么做了…
不过这个不是我这次说的重点,重点在于GitHub Action。

GitHub Action

自从GitHub被微软收购了之后,开始了大刀阔斧的更新改革,例如GitHub的手机客户端、宽屏幕的Web网页,当然还有这一小节的主角:GitHub Action。

准备工作

创建KeyStore

如果你想搞一个带有签名的APK的话(如果要上架商店的话必须带签名),必须搞一个KeyStore。这也不难,Android Studio就带有这样的功能。点击菜单栏中的「Build」-「Generate Signed Bundle / APK…」,不用管选项,直接在新弹出来的窗口中点击Next,如图1所示。
持续更新的Android & Android Jetpack & Material Design & Material Design Component(MDC)入门学习笔记

图1 创建KeyStore Step 1

在弹出来的窗口中,你可以看到「Create new…」的选项,这个选项就是新建新的KeyStore,在弹出来的窗口中,填入一些信息,填完确认即可,如图2。
持续更新的Android & Android Jetpack & Material Design & Material Design Component(MDC)入门学习笔记

图2 创建KeyStore Step 2 我现在这个版本的Android Studio是4.2 Canary 12,在创建的时候出现了
Warning: Different store and key passwords not supported for PKCS12 KeyStore, Ignoring user-specified -keypass value.

的问题,创建倒是可以创建,不知道能不能用。所以我还是乖乖的把store的密码和key的密码设成了一个

加密KeyStore

当然,对于开源项目来讲,直接将jks文件放上去不保险,有可能就被破译掉了。所以我一般会进行一波加密。在Linux环境下,可以使用openssl进行加密。我选择的是aes-256-cbc的加密方法,它需要两个数字

// encode
openssl enc -e -aes-256-cbc -iv <value1> -K <value2> -in <in_file> -out <enc_file> (-salt -pbkdf2)
// decode
openssl enc -d -aes-256-cbc -iv <value1> -K <value2> -in <in_file> -out <enc_file> (-salt -pbkdf2)

你可以随机生成,也可以取固定值(比如MD5、SHA1之类的)但是要千万记住这两个数。

配置Gradle

别忘了在app/build.gradle.kts中设置一下signingConfigs,要不然搞了这么多没有配置签名那怎么生成签名后的APK呢?
在上述文件里的android闭包中添加这几行

// written in Kotlin
signingConfigs {
    create("github") {
        storeFile = file("../<file_name>.jks")
        storePassword = System.getenv("STORE_PASSWORD")
        keyAlias = System.getenv("KEY_ALIAS")
        keyPassword = System.getenv("KEY_PASSWORD")
    }
}

如果你使用的是Groovy写的Gradle配置文件,那么应该加入这些

// written in Groovy
signingConfigs {
    github {
        storeFile file("../<file_name>.jks")
        storePassword System.getenv("STORE_PASSWORD")
        keyAlias System.getenv("KEY_ALIAS")
        keyPassword System.getenv("KEY_PASSWORD")
    }
}

大写字母那一串可以自定义,但是要和下一小节提到的Secrets中的「Name」要相同。

在GitHub上设置参数

这两个数直接写在配置文件里是非常不好的,好在GitHub上有一个东西叫作Secrets,专门用于放这东西的。
打开你的repo,选择「Settings」,左侧栏里有「Secrets」,新建几个变量,将刚才的那两个值放进去。记住Secret的「Name」,待会有用。当然你还可以创建更多的Secrets,比如key密码、store密码和alias等信息,方法是一样的,见图3。
持续更新的Android & Android Jetpack & Material Design & Material Design Component(MDC)入门学习笔记

图3 Secrets

好了,可以正式开始设置配置文件了!

设置配置文件

这里是核心,配置文件写好了可以一次性通过。如果写不好可能还得再改。
在项目根目录里创建.github/workflows文件夹,并新建一个文件叫android.yml(名字我不记得能不能改了,应该可以)并按照下面的模板进行创建。注释里的内容是我自己摸索出来的,如果哪里写的不对欢迎来指证。

# 名称,可改
name: Android CI
# 在什么操作下使用CI
on: [push, pull_request]
# 任务
jobs:
  android:
    name: Android
    # 在什么环境中运行
    runs-on: ubuntu-latest
    # 步骤
    steps:
    - uses: actions/checkout@v1
    - name: build apk
      # 运行什么指令,需要使用哪种环境变量,用$name来使用
      run: |
        # 这里可以执行你的openssl decode命令
        chmod +x gradlew
        ./gradlew assembleRelease
      # 环境变量
      env:
        key: ${{ secrets.key }}
        iv: ${{ secrets.iv }}
        STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
        KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
        KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
    # 如果还需要执行什么任务,可以在下面继续写

配置完毕之后就可以上传了!然后就等待你的工程build完毕即可。
一般情况下,配置CI是很费时间的,需要耐心,多调几次。但是一但调好,你就可以直接上架应用商店。

2021.1.6更新!Android Gradle Plugin 7.0之后的Java 11需求

众所周知,Android Studio迎来了全新的版本号2020.3,和Jetbrains软件版本号对齐,同时拥有了Android Studio首个使用动物名称命名的Arctic Fox版本;并且,Android Gradle Plugin(AGP)也更新了版本号,从4.2直接跳跃到了7.0,这是为了对齐Gradle的下一个重大版本更新。而AGP 7.0带来的最大的改动就是将Java的最低支持版本升级到了Java 11(哭哭,爸爸们都不爱Java 8了)

读者可能会想了,Kotlin有自己的一套编译,Java版本变化跟Kotlin有啥关系呢?可是根据我的经验,它确实有影响。

首先在app层级中的Gradle配置文件中,就要去掉compileOptions规定的使用Java 8的字段。

compileOptions {
  sourceCompatibility = JavaVersion.VERSION_1_8
  targetCompatibility = JavaVersion.VERSION_1_8
}

同时,在Android Studio里面的设置中([File]-[Settings])你还得需要看一下它是否在使用Java 8,如果是,那么建议改成Java 11。最好将kotlinOptions.jvmTarget字段也改成11,这样做保险一些。

当然,Github Action中如果不改变Java版本也会出问题,如图所示。
持续更新的Android & Android Jetpack & Material Design & Material Design Component(MDC)入门学习笔记

图4 Github Action报错

这时候只能使用Github Action给我们提供的Java Setup了。

在你的yml文件中的step下加入

- uses: actions/setup-java@v1
  with:
    java-version: '11.0.9' # The JDK version to make available on the path.
    java-package: jdk # (jre, jdk, or jdk+fx) - defaults to jdk
    architecture: x64 # (x64 or x86) - defaults to x64

具体字段意思可以通过上面的链接查询,这样在build的时候,自动将JAVA_HOME设置为Java 11的Home路径了。

从最基础的布局开始:ConstraintLayout 1.x

忘记了从什么时候开始,Android Studio默认将ConstraintLayout作为默认布局。你每新建一个layout xml,它都会出现。好用吗?我们来看看。

直接放置组件

之前说过,这是有史以来第一次Android实现了拖动放置模块,也就是说你可以在xml的Design页面中将组件直接拖进去,稍作设置即可。如果是以前的话,你可能会使用LinearLayout进行各种设置:orientation必不可少,稍微高级一点还可以用到layout_weight。可是,这些你都得手动输入,或者在右侧面板里去找属性。
现在,如果你使用ConstraintLayout,你可以通过左侧的选取上下左右跟谁设置相对位置,右侧选择距离是多少就搞定啦!
持续更新的Android & Android Jetpack & Material Design & Material Design Component(MDC)入门学习笔记

图4 ConstraintLayout编辑界面

持续更新的Android & Android Jetpack & Material Design & Material Design Component(MDC)入门学习笔记

图5 ConstraintLayout组件编辑界面

代码微调

如果你在拖动组件上出现了问题(比如有的时候拖不过去,或者不能正确Constraint),你就需要进行代码上的微调。
看代码其实很简单,所有有关Constraint的组件都以app:xxx的形式出现在组件属性里,你需要在xml最上面引用

xmlns:app="http://schemas.android.com/apk/res-auto"

在ConstraintLayout中,所有用于限制组件位置的属性都以app:layout_constraint开头,后面紧接着的是组件的哪个位置其他组件的哪个位置进行对齐。例如:layout_constraintTop_toBottomOf的意思是说这个组件的顶边和等号后面的组件的底边对齐;layout_constraintStart_toEndOf的意思是说这个组件的起始边和等号后面的组件的尾边对齐。当然你也可以使用Left和Right,这样就是左边和右边,但是在RTL(右向左,多出现在阿拉伯文中)的布局中会出现问题。所以,根据这样的限制之后,如果长宽的确定的情况下,就可以只通过两个constraint属性固定住一个组件。
持续更新的Android & Android Jetpack & Material Design & Material Design Component(MDC)入门学习笔记

图6 几个重要的边 图源自Android官网

还有一个概念叫作偏移(bias),在上下或者左右已经constraint之后可以加入偏移这个属性,它允许你放在一定比例的位置,比如图7中的这样,设置layout_constraintHorizontal_bias或者layout_constraintVertical_bias为0.3,它就会出现在30%的位置上。
持续更新的Android & Android Jetpack & Material Design & Material Design Component(MDC)入门学习笔记

图7 偏移 图源自Android官网

但是,我们需要考虑一个问题:大家都知道组件的长宽layout_widthlayout_height两个属性是有两个常规取值的(wrap_content和match_parent),但是如果我想让边界不超过一个组件的边界,但是又想让他充满剩余空间,那怎么办呢?
ConstraintLayout为我们提供了一个新的值:match_constraint。其实这个值写在xml里就是0dp,只不过你可以在图5的ConstraintLayout组件编辑界面右侧的属性列表中看到这个值。这个值的意思是让你规定的Constraint约束条件来确定组件的长宽。如果你确定要设置这个值,一定要对两条边都要设置constraint属性。如果是长为0dp那么就是layout_constraintStartlayout_constraintEnd,如果是宽为0dp那么就是layout_constraintToplayout_constraintBottom
对了,有一条边很重要,那就是parent,这个是整个ConstraintLayout的边界,如果你跟这个对齐就是跟最外侧的那个边进行对齐。说白了,你至少有一个组件是要用到parent的,否则整个constraint是不会起效果的。
剩下的就是自己设置Margin和Padding了,来保证各个组件之间有一定的距离。
还有一些特殊的constraint方法,例如baseline以及circle等等,我目前的应用场景还没有用过,等我自己先研究一下之后再写进来。

进阶玩法

你以为这就完了吗?还有一些很有意思的东西没有说到。

Helpers

在设计界面中右键空白处可以看到有一个「Add helpers」,这是ConstraintLayout提供的一些辅助的东西。例如里面的Guideline辅助线,屏障barrier,组Group等等。
持续更新的Android & Android Jetpack & Material Design & Material Design Component(MDC)入门学习笔记

图8 Helpers

简单说一下这几个,这几个我自己的话是没有用过的,后续如果我自己用过还会在此更新我自己用过后的体会。
Barrier的应用场景是为了处理多个不等长的组件在其后面进行constraint的情况,它可以在多个控件的一侧建立一个屏障。
Guildline就是辅助线,不会在App中显示,你需要给它指定位置。
Group可以将多个组件打包,可以对他们执行同一种操作,例如隐形。
Placeholder(占位符)可以让一个组件移动到其所在的位置上。

Chain

这可能是我用的最多的东西了。
平常我总是能遇到一个很重要的问题:比例分配空间。在LinearLayout中,可以设置layout_width为0,使用权重layout_weight进行几等分划分。
持续更新的Android & Android Jetpack & Material Design & Material Design Component(MDC)入门学习笔记

图9 链 图源自Android官网

在ConstraintLayout上,官方提供了一个叫作chain的东西,比LinearLayout的高级了很多。你可以将好几个组件在同一个轴上合在一起形成一条链(Chain)。这个链可以切换不同的形态(具体的属性名称为layout_constraintHorizontal_chainStylelayout_constraintVertical_chainStyle),例如距离等宽、最边上两个组件贴边其他距离等宽、权重分配宽度、合并和带偏移的合并。是不是比LinearLayout强大多了?

持续更新的Android & Android Jetpack & Material Design & Material Design Component(MDC)入门学习笔记

图10 链的形态 图源自Android官网

必须使用的绑定组件工具:ViewBinding

要说在绑定组件这方面,你的第一反应是什么?findViewById?还是Butterknife?再高级一点,Data Binding?接触Kotlin之后发现还学会了Kotlin Synthetics(Kotlin Android Extensions)?

现在,这里要介绍一个全新的绑定组件工具:View Binding,而且这是官方推荐的工具。我在使用过后发现写起来没有那么多冗余代码,而且很顺手,这也就是为什么我说每个写Android程序的人都要使用一下。

那么现在我们来看一下几个问题:它是啥?为什么用它?怎么用

Binding的那些事

说到这,View Binding到底是啥?它是一个将业务代码和视图连接起来的工具。如果想要说得更细一些,那么就要说到什么是绑定(Binding)。

我们都知道,页面上有多个控件,单独将他们放在xml代码中几乎没有交互(注:MotionLayout的动画是一个列外,它可以通过一个xml文件即可实现动画)那么交互写在哪里呢?写在了具体的Activity和Fragment当中。但是你在Activity和Fragment中怎么调用呢?这就是绑定(Binding)所要解决的事情。findViewById(view)这个函数就是干这个用的,但是它有很多问题,例如空指针安全、冗余代码过多等等。因此,大佬Jake Wharton创造了Butterknife来解决这个问题,这个工具一度成为Kotlin出现前最主要的绑定工具。值得一提的是大佬在Kotlin被宣布成Android官方语言之后就去了Google,去年(2020年)中的时候他说他又回到了Square。

Butterknife很好用,通过注解的方式可以直接调用对应的对象进行操作,如果是按钮点击事件的话也可以单独写成一个函数,将这个函数加上注解就可以了,无需设置监听器(Listener)。如果各位去翻看我的BJUTLoginApp项目的3.0版本的话还能看到我使用了这个库。但是使用它的项目在build的时候会很慢,而且有的时候会出现一些奇奇怪怪的问题。

那时候还有一个工具叫做Data Binding,说实话我没怎么使用过,只知道它需要对xml文件加入layout和data字段,然后在对应的TextView中直接使用类中的成员。当时我觉得这东西太复杂了就没有深入的研究过,后来发现Data Binding只有根标签是layout的时候才会生成对应的Binding类,而且build速度上用了annotation processor导致很慢。

到了Kotlin成为Android官方开发语言之后,JetBrains他们搞出了一个叫做Kotlin Android扩展(Kotlin Android Extensions),借用Kotlin语言的一些特性,帮助开发者解决绑定视图。KAE会将xml文件中的每一个有android:id属性的组件都会创建一个对应的对象,对象的名称为该字段的名称,一般情况下都放在了kotlinx.android.synthetic.main.*下面,main后面会接对应的文件名,然后是各个组件。它有一个很严重的问题,如果你引入的包不对会出现NullPointer的错误,而且当你使用RecyclerView的时候也会出现问题。来看下面的例子:

class UserAdapter(var users: List<User> = mutableListOf()) : RecyclerView.Adapter<UserAdapter.UsersViewHolder>() {
    inner class UsersViewHolder(val view: View) : RecyclerView.ViewHolder(view)
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        UsersViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_users, parent, false))
    override fun getItemCount() = users.size
    override fun onBindViewHolder(holder: UsersViewHolder, position: Int) {
        holder.view.user.text = users[position].user
    }
}

这是一个简单的RecyclerView.Adapter的写法,在后面我会说到在View Binding中的通用写法。在KAE中,在onBindViewHolder(holder, position)函数中应该定义每个组件的行为,但是当用到了xml中的组件的时候应该引入哪个包呢?kotlinx.android.synthetic.main.item_users.*?不,是kotlinx.android.synthetic.main.item_users.view.*。因为组件是在ViewHolder中定义的view中使用的,如果此处你采用了前者,那么会出现空指针的错误。也就是说,前者是在Activity、Fragment中直接调用的时候引入,后者是在View对象中使用的时候引入。这个十分容易被混淆。现在,KAE与JetBrains Anko一道,退出了历史的舞台。

View Binding的出现解决了上述的问题,既能保持像KAE的书写优雅,又能像Data Binding这种保证编译类型的安全,同时build速度还很快。下表总结了各个绑定工具优劣:

区别 findViewById Butterknife Data Binding Kotlin Android Extension View Binding
代码优雅程度 ×
编译类型安全 × × ×
Build速度 × ×
双语言支持 ×
空安全 × - ×

注1:KAE从来都没有被Android官方设为推荐使用的绑定工具!但View Binding是。

注2:双语言支持指Java与Kotlin。

View Binding的用法

说了这么多,现在正式开始介绍View Binding!

开启项目对View Binding的支持

View Binding是一个Build Feature,因此你需要在app/build.gradle.kts中的android闭包下添加:

buildFeatures.viewBinding = true

这样,在Gradle build之后工程会根据所有res/layout下的xml文档生成一个对应的类,命名名称是将原文件名的下划线命名法的命名改为帕斯卡命名法的命名,并在末尾加上Binding,例如常见的activity_main.xml会生成ActivityMainBinding

使用View Binding生成的对象

如果你要在Activity、Fragment中使用,也很简单:

  1. 创建binding对象并初始化:
     private val binding by lazy {
         ActivityMainBinding.inflate(layoutInflater)
     }
    
  2. 在Activity和Fragment中设置View
    // Activity
    override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      setContentView(binding.root)
      ...
    }
    // Fragment
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        binding = initBinding(inflater, container)
        return binding.root
    }
    
  3. 使用它吧!你的所有的对象都在binding对象下!
    binding.fab.setOnClickListener{ doSomething() }
    

进阶玩法:RecyclerView中使用

RecyclerView作为最常用的列表控件,它也是可以使用View Binding的,其实写法和KAE差不多,只不过得把ViewHolder中的View换成对应的Binding即可,初始化的话Viewbinding接口中唯一一个函数就是会返回一个View:

inner class UsersAdapter(var users: List<User> = listOf()) : RecyclerView.Adapter<UsersAdapter.UsersViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
            UsersViewHolder(ItemUsersBinding.inflate(layoutInflater))
    ...

    inner class UsersViewHolder(val binding: ItemUsersBinding) : RecyclerView.ViewHolder(binding.root)
}

进阶玩法:设计BaseFragment或BaseActivity

为了降低代码的重复率,你可以设计自己的Fragment或者Activity,将他们的共同点都集成在父类中,例如每个Fragment和Activity都会使用到View Binding。

那么你得了解一下View Binding的类们到底是啥,其实它很简单,只是一个接口:

package androidx.viewbinding;

import android.view.View;
import androidx.annotation.NonNull;

/** A type which binds the views in a layout XML to fields. */
public interface ViewBinding {
    /**
     * Returns the outermost {@link View} in the associated layout file. If this binding is for a
     * {@code <merge>} layout, this will return the first view inside of the merge tag.
     */
    @NonNull
    View getRoot();
}

唯一的一个方法就是getRoot(),在Kotlin里会简单写作root属性。在上面的代码中你可以发现Activity只需要binding对象的root,创建的时候虽然只需要Activity自带的layoutInflater,但是它需要调取inflate静态方法,而这个是在生成之后才会有,那问题就好解决了:

abstract class BasicActivity<T : ViewBinding> : AppCompatActivity() {
    val _binding: T? = null
    val binding get() = _binding!!

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        _binding = initBinding
        setContentView(binding.root)
    }

    override fun onDestory() {
        _binding = null
        super.onDestory()
    }
    abstract fun initBinding(): T
}

Fragment也是一样的道理:


abstract class BasicFragment<T : ViewBinding> : Fragment() {
    private var _binding: T? = null
    val binding get() = _binding!!

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        _binding = initBinding(inflater, container)
        return binding.root
    }

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

    abstract fun initBinding(inflater: LayoutInflater, container: ViewGroup?): T

    abstract fun initViewAfterViewCreated()

    override fun onDestroyView() {
        _binding = null
        super.onDestroyView()
    }
}

用的时候只需要多调用一步initBinding(*)即可。

这里也求教一下各位,谁有更简洁的写法欢迎提供一下!

全新的可持续化数据方式:DataStore

自从Android诞生以来,持久化数据存储就有好多种手段,其中Preference被常常提起。一般来说,Preference是用于存储App的用户设置,并且Android还有PreferenceActivity、PreferenceFragment帮忙构建一个比较美观的设置页面。但是由于苹果的UI设计方案过于深入人心,许多软件为了在Android上模仿苹果的设计一般都不使用原生和之后推出的Material Design设计语言。

不过这一节并不是来说如何设计设置页面,而是来说一下它的后台。原版的Preference早就已经停用,现在的Preference、PreferenceFragmentCompat都是使用的支持库的东西(现在已经转移到了androidx.preference),而且已经停止了更新(版本号为1.1.1,最近更新时间为2020年4月15日)说明Google自己都认为这个API过于古老了。而且Google已经不再推荐使用PreferenceActivity,建议改用PreferenceFragmentCompat,在原本的Activity中使用替换布局的方式替换。而且一堆带有Compat的类,搞的这个API过于臃肿。既然Google已经决定弃用,那么是不是应该出一个新的?

于是,Jetpack DataStore就出现了。本节的剩余部分就是来介绍这个全新的API。

**注:**本文书写是所用的版本为1.0.0-alpha06,即DataStore现在处在Alpha阶段,非常不稳定,API会随时出现重大改动,慎重更新!本节也会稍微写一下可能会影响到改动你自己的程序代码的改动介绍。

写在前面

由于DataStore是一个全新的API,现在能获取到的信息很少,中文的资料就更少了。在我写这一部分的时候,中文相关文章不超过3篇,而且用的时候你会发现和Preference差的太远了,直接将Preference替换成DataStore是跑不出来的,因此这里给一些真正能够让你跑通的资料,好好研究一下。

  • 官方的Codelab:永远的神,任何你不懂的东西都要去这里看看,尤其是在刚刚出现新的API的时候,官方教你写的代码大多数都是可以跑的,否则就不能作为教程出现。链接在这里,请自取,需要*。
  • Kotlin Flow:原本的DataStore(1.0.0-alpha06)之前的DataStore实现都是使用的Kotlin Flow和协程(Coroutine),这里需要你对这俩有一定认识。这部分资料还是比较多的,本文会涉及到一些但是不会说的非常详细(因为我自己还在研究这个Flow)
  • RxJava:如果你不会使用Flow也没关系,在1.0.0-alpha06的时候加入了两个全新的库:androidx.datastore:datastore-rxjava2:1.0.0-alpha06androidx.datastore:datastore-rxjava3:1.0.0-alpha06,在使用这两个库的时候需要你对RxJava较熟悉。本文主要写有关Flow的,不会涉及到RxJava的内容,需要你自己来尝试尝试。

什么是DataStore?

DataStore是一种全新的持久化数据存储API,原本是基于Kotlin协程和Kotlin Flow开发的,用到了许多Kotlin的语言特性。DataStore分为Preferences DataStore和Protobuf DataStore,这里主要介绍前者,后者我还需要自己慢慢探索。简单来说前者比较容易存储一些基础类型,但是比较复杂的存储就只能使用后者,后者存储较多较复杂数据的时候性能会高一些。值得一提的是,Protobuf也是Google搞出来的东西。

DataStore到底有啥优势?

用一个表格来说明问题吧:

特色 SharedPreferences Preferences
DataStore
Proto
DataStore
异步操作
(仅在通过listener读改动过的数据)

(通过Flow)

(通过Flow)
同步操作
(在UI线程中操作会有安全风险)
x x
UI线程使用的
安全性
x
(会转到Dispatchers.IO)

(会转到Dispatchers.IO)
可以发出错误信号
Can signal error
x
在运行异常中的安全性 x
具有高度一致性保证的事务性API
Has a transactional API with strong consistency guarantees
x
数据迁移 x
(从SharedPreferences)

(从SharedPreferences)
类型安全性 x x √(Protocol Buffers)

该表格摘自Codelab。

如何使用Preferences DataStore?

Preferences DataStore用法很简单:创建、在协程体里操作和编辑或查看。它只支持Int、Double、String、Boolean、Float、Long、StringSet。

所有的教程就会这样告诉你:

// 创建DataStore
val dataStore = context.createDataStore(Constants.DATASTORE_NAME)
// 查看键值
dataStore.data.map {
    i = it[key]
}
// 编辑键值
dataStore.edit {
    it[key] = value
}

乍一看,好像没啥不对的。但是,mapedit都是挂起函数(suspend function),这也就意味着这些函数只能出现在协程体里。但是,当你直接在协程体里运行这个代码(例如ViewModel中的viewModelScope.launch)仍然会有问题:虽然会在/data/data/<package>/files/datastore下能找到对应的文件,但是却无法存储键值。这是一个坑,我就是这么坑进去的>_<

于是,我看完Codelab示例程序之后我才知道应该怎么才能正确使用DataStore。

Kotlin Flow

简单来说一下Flow这个东西。在DataStore中Flow是主角,相比于挂起函数可以返回多个结果,而且是异步地返回。可以看一下官方在Flow API文档中的描述和示例代码。用挂起函数实现异步传数据:

suspend fun simple(): List<Int> {
    delay(1000) // pretend we are doing something asynchronous here
    return listOf(1, 2, 3)
}

fun main() = runBlocking<Unit> {
    simple().forEach { value -> println(value) } 
}

这样的写的话,suspend function会返回一个数组,主进程不会被阻塞,程序执行后1s就会立刻执行循环。而Flow的话就可以实现每次只输出一个列表中的元素,将Flow的泛型设置为Int:

fun simple(): Flow<Int> = flow { // flow builder
    for (i in 1..3) {
        delay(100) // pretend we are doing something useful here
        emit(i) // emit next value
    }
}

fun main() = runBlocking<Unit> {
    // Launch a concurrent coroutine to check if the main thread is blocked
    launch {
        for (k in 1..3) {
            println("I'm not blocked $k")
            delay(5000)
        }
    }
    // Collect the flow
    simple().collect { value -> println(value) } 
}

这样也可以不阻塞主进程,输出则是launch中的println和collect中的println交替执行。

Flow的函数中,所有不带suspend修饰的函数都是中间函数(例如map,filter,take,zip),最后都要转化为带有suspend修饰的函数的末端函数(例如collect,collectLatest,single,reduce)。

键值对

Preferences DataStore使用的是键值对(Key-value),使用的Key是一个类:Preferences.Key。在1.0.0-alpha05及以前的版本如果想要定义键,定义的方式是preferencesKey<Type>(name),在1.0.0-alpha06中,这个API改掉了,估计是怕用户输入这个泛型可能会越界,因此他们将这个定义方式改成了intPreferencesKey()doublePreferencesKey()等等。

当要检索值的时候,可以使用dataStore.dataget()函数进行检索,就比较类似于SharedPreference的getString()getFloat()之类的;当需要编辑值的时候,调用dataStore.edit()函数直接编辑,这个也和SharedPreference类似。

创建操作符单例类

问题来了,怎么使用呢?我自己试过直接在协程体里用是无法生效的,因此我去看了一下Codelab,它是用了一个特殊的单例类,在单例类里面执行创建DataStore的操作。由于创建操作需要在context中创建,因此在创建单例类的中需要传入一个参数,这样的话object关键字就用不了了(Constructors are not allowed for objects)我采用懒汉式定义方法:

class DataStoreOperator private constructor(val context: Context) {
    private val dataStore: DataStore<Preferences> =
        context.createDataStore(Constants.DATASTORE_NAME)

    suspend fun setSomeValue(value: Int) = setValue(key, value)

    private suspend inline fun <reified T> setValue(key: Preferences.Key<T>, value: T) {
        dataStore.edit {
            it[key] = value
        }
    }

    companion object {
        private var operator: DataStoreOperator? = null
        fun instant(context: Context) =
            if (operator == null) {
                DataStoreOperator(context).also { operator = it }
            } else operator!!

        fun instant() = if (operator == null) {
            throw NullPointerException()
        } else operator!!
    }
}

这段代码中我还多了一个函数:setValue(),为后面创建编辑每个键值的函数做准备(之后所有的编辑操作都可以使用简单的setSomeValue())。

单例类的初始化是调用instant()函数。一般情况下,第二个instant()可能会用不到,因为你可以只在自定义的Application类中初始化,这样的话你只要有Context就可以获取到Application对象。

读取和修改键值

上一个小节已经解决了如何修改键值,那么这个小节来解决如何读取。DataStore其实可以一次性读取多个键值,读取的时候需要使用到dataStore.data这个Flow<Preferences>对象。在上面介绍Flow的时候我已经说过用末端函数来返回结果,因此你可以将你想一次性输出的所有的键值封装到一个类中:

data class Settings {
    val i: Int,
    val e: Enumber
}

enum class Enumber {
    FIRST, SECOND
}

这里举例特意举了一个枚举类的例子,虽然Preferences DataStore只支持那些类型,但是枚举类比较特殊,是可以转化成String类型的。那么读取或修改的代码如下:

// 键值的定义
val keyI = intPreferencesKey("int")
val keyE = namePreferencesKey("int")
// 读取函数
val settings = dataStore.data
    .catch {
        if (it is IOException) {
            emit(emptyPreferences())
        } else throw it
    }
    .map {
        val i = it[keyI] ?: -1 // 如果找不到可以使用?:操作符设置默认输出值,与SharedPreference中的defValue参数类似
        val e = Enumber.valueOf(it[keyE] ?: Enumber.FIRST.name)
        Settings(i, e)
    }
// 修改函数
suspend fun setI(i: Int) = setValue(keyI, I)
suspend fun setE(e: Enumber) = setValue(keyE, e.name)

这些函数都应该写到上一小节中的DSO类(DataStoreOperator)里。

使用

使用的话就很简单了,之前就说过直接在协程体里使用即可。ViewModel中自动提供一个viewModelScopeActivity中有lifecycleScope,调用其中的launch()函数即可使用:

class MainViewModel(app: MyApp): AndroidViewModel(app) {
    val dso = getApplication<MyApp>().dataStoreOperator
    init {
        viewModelScope.launch {
            val settings = dso.settings
            dso.setI(1)
            ...
        }
    }
}

顺带提一句,如果你要使用ViewModel建议使用AndroidViewModel,这个要在构造对象的时候输入一个Application对象,使用getApplication<MyApp>()函数在函数体里调用你自己的Application,方便你使用里面的东西,例如Room数据库、DataStore、Preference、Resources等。

版本更迭

最后来总结一下DataStore的版本更迭吧:

时间 版本号 更新
2021.01.13 1.0.0-alpha06 添加RxJava封装容器,移除preferencesKey<T>(name: String): Key<T>方法,并替换为每种受支持类型专用的方法,隐藏CorruptionHandler接口
2020.12.02 1.0.0-alpha05 允许(但不强求)关闭传递给Serializer.writeTo()OutputStream
2020.11.17 1.0.0-alpha04 修复重大bug
2020.11.11 以前版本 支持双精度拥有重大Bug:导致Preference Datastore崩溃并显示java.lang.NoClassDefFoundError

未完待续

上一篇:QT的QSGGeometryNode类的使用


下一篇:一百六十