个人开源库的一些更新,兼谈Jetpack和Kotlin给Android开发带来的变化

4,115 阅读14分钟

前段时间,我开发完成了新的软件 移动工具箱。最近,我准备把开发过程中总结的一些东西沉淀到自己个人开源的几个库中。最新的一些更新中运用了 Kotlin 和 Jetpack 的一些语法特性,故此总结一下。Jetpack 和 Kotlin 出来已经很久了,然而很多应用开发还停留在 MVP 以及 Java 阶段,即便使用了 Kotlin,很多人只不过是像使用 Java 一样在使用 Kotlin. 实际上,如果能够结合 Kotlin 和 Jetpack 的语法特性,可以大大提升我们日常开发的效率。下面,我以个人开源库的一些新的 Feature 的更新来说明,Jetpack 带来的新变化以及 Kotlin 的特性的运用。

1、Android-VMLib

这个库的地址是:github.com/Shouheng88/…

1.1 新的数据交互设计:隐藏 LiveData

其实早在之前的文章中我也提及过这个库 《2020 年,我这样在项目中使用 MVVM》。不过,在新的版本中我又做了一些改动。首先是将应用依赖的一些三方库提升到了最新版本。其次是,在 BaseActivity 和 BaseFragment 中,我又增加了几个方法,

// 新增观察方法,ui 层通过指定 class, flag 和 single 直接对数据进行监听
protected fun <T> observe(dataType: Class<T>,
                          flag: Int? = null,
                          single: Boolean = false,
                          success: (res: Resources<T>) -> Unit = {},
                          fail: (res: Resources<T>) -> Unit = {},
                          loading: (res: Resources<T>) -> Unit = {}) {
    vm.getObservable(dataType, flag, single).observe(this, Observer { res ->
        when (res?.status) {
            Status.SUCCESS -> success(res)
            Status.LOADING -> loading(res)
            Status.FAILED -> fail(res)
        }
    })
}

其实之前的文章中,我也提到过这个问题。我们通过自定义的枚举将 ui 层和 ViewModel 的交互划分为 “成功”、“失败” 和 “加载中” 三种状态,然后在 ui 层根据状态做判断来分别处理各种状态。那么,既然这样,我们为什么不将每个状态写成一个方法分别进行回调呢?当然,这里我们使用 Kotlin 的函数式编程更好地实现了这个目标。并且,我们为每个函数指定了一个默认的为空的实现,这样用户只需要根据自己的需要实现指定的状态即可。

另外,在 ViewModel 中我增加了下面几个方法,

// 顶层 viewmodel 新增方法,包装了三种状态的交互
fun <T> setSuccess(dataType: Class<T>, flag: Int? = null, single: Boolean = false, data: T) {
    getObservable(dataType, flag, single).value = Resources.success(data)
}

fun <T> setLoading(dataType: Class<T>, flag: Int? = null, single: Boolean = false) {
    getObservable(dataType, flag, single).value = Resources.loading()
}

fun <T> setFailed(dataType: Class<T>, flag: Int? = null, single: Boolean = false, code: String?, message: String?) {
    getObservable(dataType, flag, single).value = Resources.failed(code, message)
}

其实也就是将之前获取 LiveData 的逻辑包装了一层,然后将三个状态分成三个方法。所以,现在 ui 和 ViewModel 的交互变成了下面这样,

就是说,UI 层通过调用 ViewModel 的方法进行请求(图中①所示流程),当 ViewModel 完成了请求之后通过调用 setSuccess(), setLoading()setFailed() 方法来把状态通过 LiveData 传递给 UI 层。UI 层通过 observe() 方法进行监听。这里我们使用 Class、flag 和 Boolean 来共同决定一个 LiveData. 具体来说,Class 自己可以决定 LiveData,这里的 flag 用来区别 Class 相同的情况。比如,一篇文章有标题和内容,都是 Stirng 类型,如何区分呢?此时就需要使用 flag 来做区分。这里的 Boolean 用来定义 “一次性的” LiveData,所谓的 “一次性” 就是指,通知了一次之后就不会再进行第二次通知了。

用一段代码来展示一下交互逻辑,

这里 ui 层通过 ViewModel 的 requestFirstPage() 发起请求,ViewModel 中通过 setSuccess(), setLoading()setFailed() 方法来把状态通过 LiveData 传递给 UI 层。UI 使用 observe() 方法进行监听,不过这里只处理了 “成功” 和 “失败” 两种状态。这种新的开发方式代码量要比之前少了许多,甚至ui层只需要一行代码就可以完成注册监听的逻辑。

在这里我们做的工作就是把 LiveData 这个概念隐藏了起来。但通过 Class 来寻找 LiveData 的开发方式还是比较新颖的,当然也许会有更好的办法来隐藏掉 LiveData 从而更好地实现注册和通知的逻辑。所以,可以说 Jetpack 的 LiveData 可能会给 Android 开发方式带来一次革新。不过,笔者认为 Android 的 MVVM 实现,仍然有所欠缺:虽然 Google 官方提供了 Jetpack 和 Databinding 的一套东西来实现 MVVM 架构,但是相比于 Vue 等的处理方式,Android 的实现方式要繁琐得多。首先,为人诟病的是 Databinding 的编译速度问题,其次 Android 进行数据绑定的时候变量必须先声明再使用,这并没有使代码变得更简洁。此外,虽然 Android 提供了 style 来定义控件的属性,但是 style 无法提供像前端的选择器那样丰富的功能,也就无法像前端那样的样式、逻辑和布局分离。这就导致了使用 xml 写的布局非常冗长,而进一步在 xml 中做数据绑定,会使得 xml 变得更加繁冗。

这里还有一点要说的就是 Kotlin 的函数式编程,从上面也可以看出,我们的 observe() 方法中通过定义三个函数来完成状态回调,而在 Java 中只能使用接口来进行回调。Java 使用接口来实现的函数式编程并不像 Kotiln 一样彻底,即便是将接口回调的方法用到 Kotlin 中也不如 Kotlin 的函数式编程优雅。所以,新的版本中,我也将部分类迁移到了 Kotlin 上面以充分使用 Kotlin 的特性。

2、Android-Utils

这个库的地址是:github.com/Shouheng88/…

2.1 新增了一波方法

首先,这个库中增加了许多新的方法,比如在代码中生成 Drawable 的方法,可以帮我们在代码中实现主题兼容,因为在自定义的 drawable.xml 文件中使用 ?attr 在低版本兼容的问题,我们可以将部分逻辑通过代码来实现,

// https://github.com/Shouheng88/Android-utils/blob/master/utils/src/main/java/me/shouheng/utils/ui/ImageUtils.java
public static Drawable getDrawable(@ColorInt int color,
                                   float topLeftRadius,
                                   float topRightRadius,
                                   float bottomLeftRadius,
                                   float bottomRightRadius,
                                   int strokeWidth,
                                   @ColorInt int strokeColor) {
    GradientDrawable drawable = new GradientDrawable();
    drawable.setColor(color);
    drawable.setStroke(strokeWidth, strokeColor);
    drawable.setCornerRadii(new float[]{
            topLeftRadius, topLeftRadius,
            topRightRadius, topRightRadius,
            bottomRightRadius, bottomRightRadius,
            bottomLeftRadius, bottomLeftRadius
    });
    return drawable;
}

另外就是对 Drawable 进行着色的逻辑。这可以减少代码中重复类型的资源,也可以更好地实现应用的主题兼容,

// https://github.com/Shouheng88/Android-utils/blob/master/utils/src/main/java/me/shouheng/utils/ui/ImageUtils.java
public static Drawable tintDrawable(Drawable drawable, @ColorInt int color) {
    final Drawable wrappedDrawable = DrawableCompat.wrap(drawable.mutate());
    DrawableCompat.setTintList(wrappedDrawable, ColorStateList.valueOf(color));
    return wrappedDrawable;
}

此外,还增加了各种进制之前转换的逻辑、震动以及获取应用的信息等各种方法。

2.2 Kotlin 的诡计:使用 Kotlin 进行拓展

另外一个比较大的更新是新增了一个 module: utils-ktx,它使用 Kotlin 的新特性对工具类做了进一步的包装让调用更加简洁。当然,不是每个人都会接受这么做,所以,我将其划分为一个单独的 module,并且生成一个单独的依赖,用户可以自由进行选择。

utils-ktx 的新增的功能可以用两个关键字来描述:赋能和全局函数。赋能就是为原有的类增加新的方法,比如 String 和 Bitmap,

// String 类新增的一些方法
fun String.isSpace(): Boolean = StringUtils.isSpace(this)
fun String.isEmpty(): Boolean = StringUtils.isEmpty(this)
// ...
// Bitmap 新增的一些方法
fun Bitmap.toBytes(format: Bitmap.CompressFormat): ByteArray = ImageUtils.bitmap2Bytes(this, format)
fun Bitmap.toDrawable(): Drawable = ImageUtils.bitmap2Drawable(this)
// ...

另一个就是全局函数。我们之前通过全局 Context 获取资源已经简化了资源获取的逻辑,现在进一步优化之后,只需要调用下面的方法就可以获取资源,

@ColorInt fun colorOf(@ColorRes id: Int): Int = ResUtils.getColor(id)
fun stringOf(@StringRes id: Int): String = ResUtils.getString(id)
fun stringOf(@StringRes id: Int, vararg formatArgs: Any): String = ResUtils.getString(id, *formatArgs)
fun drawableOf(@DrawableRes id: Int): Drawable = ResUtils.getDrawable(id)
// ...

所以,当我们希望获取一个 Drawable 并根据主题对其进行着色的时候,直接使用下面的代码就可以完成:

iv.icon = drawableOf(R.drawable.ic_add_circle).tint(Color.WHITE)

再比如,当我们想要在 Activity 中请求系统的存储权限的时候,只需要使用下面一行代码就可以完成,

checkStoragePermission { 
    // ... 添加请求到权限之后的逻辑 
}

当然,这样做固好,只是有点比较讽刺的是,以新增的 String.isSpace() 方法为例,我们新增方法的逻辑实际是通过 StringUtils.isSpace(this) 来完成的。而所谓的新增方法,实际上也就是在编译之后将新增方法的类的实例作为静态方法的第一个参数进行编译。我们这样做就相当于,代码中转了过来,而编译之后又转了回去。只是代码看上去简单了一些,而牺牲的代价是无端多了一些编译的产物。

另外一个需要注意的地方是,Kotlin 固然灵活,但如果不加以约束会使得项目变得混乱。比如,这里的新增方法和全局方法,都是全局性质的,一个模块中引入了这些依赖就具有了这些新的特性。如果每个人都随意往代码中增加类似的方法,那很显然会出现各种方法和代码冲突。所以,如果仅仅是把 Kotlin 当 Java 用还好,但新特性的使用必须加以约束。

Kotlin 的灵活性是把双刃剑,不仅局限在上述新增方法的情形中。我在开发中还遇到过使用 Kotlin 定义数据库对象的坑,这里也捎带介绍下。我在项目中使用 Room 作为数据库,Room 会在编译时根据数据库对象自动生成创建数据库 SQL. 这就涉及到了一个空类型判断的问题。我们知道数据库的列是分为 NULLABLE 和非 NULLABLE 的。Room 会在编译时比较创建数据库的 Schema,发现 Schema 不一致,即便是空约束不一致,就会要求你做做数据迁移,不然就抛异常,而 SQLite 的列更新,一项比较繁琐,需要先删后增。以下面的数据库对象为例,

@Entity
data class Repository(
    @PrimaryKey(autoGenerate = true) var id: Int?,
    var username: String,
)

假如你在定义对象的时候直接将 username 字段定义成了 String 类型的,那么你惨了!这意味着你就必须在代码中保证 username 不为 null,一旦为 null,就会抛出 KotlinNull 的异常。而某天,你突然发现了这个问题想改成 String? 类型的,对不起,只能做数据库迁移。因为列的定义已经从 not null 的变成 nullable 的了。我觉得造成这个问题的一个原因可能是我们习惯了 Java 的开发,Java 默认 String 是 nullable 的。Kotlin 中的 String 和 Java 中长得一样,但是含义完全不同。

所以,还是那个问题,Kotlin 虽然灵活,但是也是有坑的!

3、Android-UIX

这个库的地址是:github.com/Shouheng88/…

3.1 再次吐槽 Kotlin

这个库是一套 ui 的合集,我设计它的目的是用来做一套标准的 ui 库,除了常用的控件之外,我还希望它能够将部分页面作为控件对外暴露,从而简化我开发程序的工作量。但是,这个库自从开发出来一直没有对外宣扬,一个原因是,我觉得用了 Kotlin 的一些特性之后,这让我有些苦恼。

BeautyDialog 为例,这是 Android 对话框的封装类,我使用了 Kotlin 进行开发,并且使用构建者模式进行构建。让我反感的是我觉得在 Kotlin 中使用构建者模式看上去非常蠢!Kotlin 提供了许多特性来让我们直接通过 “字段引用” 的方式为实例赋值。使用构建者模式之后就完全体现不了 Kotlin 的这种优雅性。另外,我一般会选择使用一些 IDEA 插件来辅助生成构建者模式需要的代码,然而在 Kotlin 中无法使用,必须手动做这类低级的工作。

另外,我通常使用自定义的注解来取代枚举,但是在 Kotlin 中,将这种自定义的枚举应用到 when 语句的时候失去了它的检查和 “提醒” 机制,使我不得不看一下枚举的定义才能知道该使用那个整数。

此外,使用 Kotlin 的其他特性,比如为方法指定默认参数等,这增加了代码的灵活性的同时也给人带来不少困扰。以上面的构建者为例,如果把方法的默认参数当作一次赋值,那么实际上为类实例的一个字段赋值的时候存在了多种可能性——可能是方法默认参数的值、可能是构建者字段的默认值、可能是类实例字段的默认值。选择多了,困惑也多了。

以上还仅仅局限于 Kotlin 调用 Kotlin 的情况。如果 Java 中调用 Kotlin 的方法呢,那么以上的 Kotlin 的特性,包括接口的默认方法在 Java 中表现又当如何呢?所以,如果仅仅是做业务开发,使用 Kotlin 可以清爽许多,但如果把 Kotlin 应用到类库的开发中的话,这种灵活性就变成了一把双刃剑。

不过,Kotlin 的一些特性也确实能让我们更好地实现一些功能,比如下面这个防止连续点击的应用:

// 一个自定义的 OnClickListener
abstract class NoDoubleClickListener : View.OnClickListener {

    private var lastClickTime: Long = 0

    override fun onClick(v: View) {
        val currentTime = System.currentTimeMillis()
        if (currentTime - lastClickTime > MIN_CLICK_DELAY_TIME) {
            lastClickTime = currentTime
            onNoDoubleClick(v)
        }
    }

    protected abstract fun onNoDoubleClick(v: View)

    companion object {
        var MIN_CLICK_DELAY_TIME                       = 500L
    }
}

// 为 View 添加方法,以后使用这个方法来替换 setOnClickListener 就可以了
fun View.onDebouncedClick(click: (view: View) -> Unit) {
    setOnClickListener(object : NoDoubleClickListener() {
        override fun onNoDoubleClick(v: View) {
            click(v)
        }
    })
}

其实,NoDoubleClickListener 这个抽象类已经可以帮助我们实现防止连续点击的目的,但是使用了 Kotlin 的特性之后,我们可以为 View 增加一个方法并且使用 Kotlin 的函数式编程,于是像下面这样就可以实现防止连续点击了,简洁得多了吧:

btnRateIntro.onDebouncedClick {
    // do something
}

3.2 OpenCV 开发环境

在这个类库中,我最近新增了一个 module,即 uix-image 模块。这个模块内搭建了 OpenCV 的开发环境并且也包含了 OpenCV 的拓展库。在我的应用 移动工具箱 中,我用这个模块实现了包括透视变换、镜像翻转、浮雕等十几种图像效果。开源的代码中只实现了两种基本的特效,以及裁剪的逻辑。如果你想要了解在 Android 里面使用 OpenCV 的话,也可以直接使用这个代码哦~

3.3 一点遐想

我现在个人开发项目的时候一般还是会直接使用 BRVAH 当作项目内的 Adapter. 不得不说,这个库真的好用。每当我开发自己的项目的时候,总是不由得想起前同事们还在使用原生的 Adapter 做开发。一个简单的布局要写大量的代码,然后不由得赞叹一声,BRVAH 确实好用。然而进来我发现,通过使用泛型等进行简单包装之后,甚至可以不用处处声明 Adapter,

我们可以定义一个工具方法如下,

object AdapterHelper {

    fun <ITEM> getAdapter(@LayoutRes itemLayout:Int,
                          converter: ViewHolderConverter<ITEM>,
                          data: List<ITEM>): Adapter<ITEM>
            = Adapter(itemLayout, converter, data)

    interface ViewHolderConverter<ITEM> {
        fun convert(helper: BaseViewHolder, item: ITEM)
    }

    class Adapter<ITEM>(
        @LayoutRes private val layout: Int,
        private val converter: ViewHolderConverter<ITEM>,
        val list: List<ITEM>
    ): BaseQuickAdapter<ITEM, BaseViewHolder>(layout, list) {
        override fun convert(helper: BaseViewHolder, item: ITEM) {
            converter.convert(helper, item)
        }
    }
}

// 一个自定义的方法
fun BaseViewHolder.goneIf(@IdRes id: Int, goneIf: Boolean) {
    this.getView<View>(id).visibility = if (goneIf) View.GONE else View.VISIBLE
}

然后,每当我们需要获取 Adapter 的时候只需要像下面这样即可,

adapter = AdapterHelper.getAdapter(R.layout.item_device_info_item,
    object : AdapterHelper.ViewHolderConverter<InfoItem> {
        override fun convert(helper: BaseViewHolder, item: InfoItem) {
            helper.setText(R.id.tv1, item.name)
            helper.goneIf(R.id.tv_name, TextUtils.isEmpty(item.name))
        }
    }, emptyList())

按照上面这样的思路,我们甚至可以直接隐藏 Adapter 的概念,直接通过 id 和属性绑定的方法来实现一个数据列表。写了这么多年的 Android,不论 ListView 还是 RecyclerView 都存在的 Adapter 概念也许就此成为了历史~

不得不感叹,Android 开发确实已经比较成熟了~

总结

以上各个代码库均是开源的,可以到我的 Github 上面通过源码做进一步了解。除了跟技术相关的东西,偶尔我也会写点跟技术无关的东西。感兴趣的可以关注我的公众号~