阅读 1659

如何更好地使用 Kotlin 语法糖封装工具类

本文已授权[郭霖]公众号独家发布

前言

在 2019 年 Google I/O 大会上,Google 宣布今后将优先采用 Kotlin 进行 Android 开发,并且也坚守了这一承诺。使用 Kotlin 进行 Android 开发代码更少,可读性更强,并且能和 Java 代码兼容。

我之前学习了一些 Kotlin 的语法糖之后,很想运用到自己整理的工具类上。当时公司只有我学习 Kotlin,所以项目是 Kotlin 和 Java 混编的。在写工具类时突然想到一个问题,我用 Kotlin 写的工具类,调用的结果和原来 Java 工具类得到的结果不一致怎么办。比如正则,我用 Kotlin 写的和别人用 Java 写的匹配出来不一样,那我不是要兴师问罪。要么就特意保证实现逻辑和 Java 工具类一致,这么做的话为什么不直接调用 Java 工具类呢。所以当时就给公司项目在用的 Java 工具类库 AndroidUtilCode 封装扩展库。

由于绝大部分功能都实现好了,主要做的事是补充没有的功能和设计一套好用的 Kotlin API。设计 API 看似很简单,实际做起来很难。因为 Kotlin 的玩法实在太多了,并且不是用了语法糖就一定会好用,用法骚会带来一定的学习成本,代码可读性可能会更差。个人比较强迫症,在这方面思考了很多,有一些封装经验可以分享给大家。

后来的公司新项目基本是 Kotlin 进行开发,可以不用考虑对 Java 代码的兼容,就着手开始写一个纯 Kotlin 开发、尽可能轻量的 Kotlin 工具类库。得益于之前的很多思考,目前实现的还是比较满意的。

接下来给大家分享个人一些封装 Kotlin 工具类的经验和一个好用的 Kotlin 工具类库。

封装思路

我看过很多人写的 Kotlin 工具类只是单纯地把原有的 Java 工具类翻译成 Kotlin 语言,这就像当初推出 C++ 后,有些人还是用面向过程的思想写代码。并不是不能用,但是能做得更好用。所以下面介绍的是一些在 Java 不常见的语法糖和一些使用建议,帮助大家更好地在工具类使用这些特性。

Top-Level

这是 Kotlin 和 Java 一个比较大的差异,Java 的属性和方法都需要写在类里的,而 Kotlin 有 top-level property 顶级属性和 top-level function 顶级函数,可以把方法和属性写在类外面。top-level 顾名思义是最高级别的,可以理解为是全局的,在别的类里是能直接调用到顶级属性或顶级方法。

有什么用呢?比如获取 Application 对象,Java 工具类是调用 AppUtils.getApplication() 来获取,而 Kotlin 工具类可以直接获取 application 属性,能在任何的地方随时获取一个 application 属性是非常爽的事情。我们能直接获取一个 application 属性的话,何必调用 AppUtils.getApplication() 呢。绝大多数情况用 Kotlin 写一个 XXXUtils 去调用静态方法都是多此一举,明明写成顶级属性或顶级方法会更好用。

这虽然是一个很简单的特性,但是也有地方要注意一下,就是命名要把功能描述清楚,个人认为很重要。比如之前写了好一个沉浸式状态栏的功能,用法如下:

StatusBarUtils.immerse(this)
复制代码

把方法移到类的外面就能变成顶级方法:

immerse(this)
复制代码

有些人可能这么改完就算了,但这是全局方法,别人调用一个全局的沉浸方法会很疑惑这是要沉浸什么东西。之前能用“沉浸”的单词作为方法名是因为工具类名也具有信息,可以结合工具类的名称推导出是要沉浸状态栏。所以最好改成:

immerseStatusBar(this)
复制代码

顶级方法或顶级属性的命名要把功能描述清楚,因为这是能全局调用的,不要只是单纯地把原有 Java 工具类的类名给去掉。

扩展

Kotlin 可以很方便的扩展一个已经存在的类,为它添加额外的方法或属性,无需继承类或者使用装饰者模式。

我们可以进一步优化上面沉浸状态栏的用法,把方法改成 Activity 的扩展方法。在方法前面增加一个接收者:

fun Activity.immerseStatusBar() {
  ...
}
复制代码

在方法内能用 this 获取到 Activity 对象,所以原本的 Activity 参数就可以去掉了。这样就可以在 Activity 调用:

class MainActivity : AppCompatActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // ...
    immerseStatusBar()
  }
}
复制代码

Java 想实现这个用法,需要在 Activity 基类里写一个 immerseStatusBar() 方法,而 Kotlin 能直接用扩展实现,无需写基类。

其实这也是个很简单的特性,不过个人也有些注意事项给到大家:

  • 给 Any 或者常见的基础数据类型进行扩展要慎重。
  • 用法尽量符合原来的使用习惯或直觉,不要差异过大。

什么意思呢?通过扩展能玩出一些骚操作,但并不是什么用法都合适。比如我刚开始接触扩展时,什么功能都想用扩展来封装,看到打印日志要传两个参数挺麻烦的,就用扩展函数来减少一个参数,给 String 增加一个打印的扩展方法:

"Downloaded progress is $progress".logd("download")
复制代码

用法确实很骚,但是用了一段时间后觉得并不好用。用法与原来的打印日志用法差异过大,写得很别扭。要读到末尾才知道是打印日志,代码阅读性变差了,String 比较长的话可能没反应过来这行是用来打印日志的。而且在调用字符串的方法时会弹出一个很让人疑惑的代码提示。

还有看过别人给 Int 增加一个扩展属性 drawableRes 获取 Drawable,也是有类似的问题。

val drawable = R.drawable.ic_back_icon_black.drawableRes
复制代码

这两个例子在功能上都是没问题的,但是用法差异太大会降低代码阅读性,需要不少时间来适应。还给常见的类型增加了奇怪的方法联想,个人是不提倡的。用法骚并不代表着好用,不要为了用语法糖而用语法糖。

当然也有提倡的骚用法,比如给 Int 增加 dp 属性,将 dp 转为 px,用法如下:

paint.strokeWidth = 1.dp
复制代码

虽然用法也是很大差异,但是符合直觉。我们读这行代码能很容易想到是给属性设置了 1 dp 的长度,代码可读性反而更好了,这种用法是提倡的。

高阶函数

高阶函数是将函数用作参数或返回值的函数。多数人用高阶函数是用于事件回调,其实高阶函数还能很方便地实现 DSL 用法。比如 Anko Layout 的 DSL:

verticalLayout {
  editText()
  button("Say Hello") {
    onClick { toast("Hello, ${name.text}!") }
  }
}
复制代码

这样的 DSL 用法比链式调用舒服一些,而且能分层级,这是链式调用不好实现的。

那要怎么运用呢?其实有可选的配置都是可以考虑使用的,最常见的是建造者模式,比如我们很熟悉的 Glide:

Glide.with(context)
  .load(url)
  .placeholder(placeholder)
  .fitCenter()
  .into(imageView)
复制代码

我们稍微来封装一下:

fun ImageView.load(url: String?, block: RequestBuilder<Drawable>.() -> Unit) =
  Glide.with(context).load(url).apply(block).into(this)
复制代码

就这么简单地封装就可以把链式调用转为 DSL 用法。

imageView.load(url) {
  placeholder(placeholder)
  fitCenter()
}
复制代码

这样用法就和 Coil 一样了,不过还有些黄色警告需要处理,所以个人建议直接用 Coil。DSL 用法比链式调用更简洁舒服一点,还能实现多级嵌套。

属性委托

属性委托是通过 by 关键字将属性的 get、set 方法委托给 by 后面的表达式。比如:

private val viewModel: LoginViewModel by viewModels() 
复制代码

这是官方的 ViewModel 委托用法,获取 viewModel 属性时会通过 ViewModelProvider 去获得 ViewModel 实例。使用委托后我们不用管如何获得 ViewModel 了,可以专注于写逻辑代码。

有的人可能学习过属性委托,但是不知道怎么运用。其实我们在通过某种方式获取或设置属性时,就可以考虑一下属性委托合不合适。比如通过 intent 获取传递的值:

private var id: String? = null

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  val id = intent.getStringExtra("id")
}
复制代码

这里可以通过属性委托来简化代码:

private val id: String? by intentExtras("id")
复制代码

属性委托能让我们不用管如何获得和设置属性,代码更加简洁,是个不错的语法糖,可以多思考一下是否适合用属性委托。

其它经验

不重复造轮子

比如显示隐藏需要调用 view.visibily = View.GONE 略显繁琐的,所以有些人会封装扩展函数 view.visible()view.invisible()view.gone() 快速实现显示隐藏。

用起来确实比之前方便了一些,但是还有优化空间,显示隐藏经常是有个判断操作的,比如:

if (isShowed) {
  view.visible()
} else {
  view.gone()
}
复制代码

每次都要这么判断稍显麻烦,所以更优地封装方式是增加一个 view.isVisible 的 Boolean 值的扩展属性,这样就能优化成下面的用法:

view.isVisible = isShowed
复制代码

这个扩展属性不仅能用于修改显示隐藏状态,还能判断当前是否在布局上显示,用起来更加方便。

不过在封装完调用该扩展属性时,你会发现有重名的属性需要选择用哪一个,仔细一看原来官方的 core-ktx 库已经实现这个扩展属性,我们没必要再重复造轮子。

所以封装工具类之前最好先了解一下 Android KTX 库和 Kotlin 的标准库有没实现相同的功能,我们封装的工具类的定位应该是对没有的功能进行补充。 重复造轮子没有意义,而且造出来的轮子可能还不如官方的。

命名建议

上面说了我们应该是补充官方库没有的功能,那么设计用法时也建议参考一下官方库的命名和用法。

比如带参数的创建操作,官方通常会用 listOf()mapOf()xxxOf() 的命名,建议与官方统一,不建议用 createXXX() 或者 newXXX() 等命名。

还有监听事件的方法命名有些人喜欢命名为 onXXX,比如:

btnLogin.onClick { 
  // ...
}
复制代码

这样直接用介词开头很奇怪,一般方法名是动词开头。所以个人建议参考官方的命名 doOnXXX,例如:

view.doOnAttach { 
  // ...
}
复制代码

与官方库的命名规则进行统一的好处是不容易产生歧义,而且别人可能会根据以往的使用习惯,去猜想你的工具类会不会有某个功能。比如想看下有没有某个监听事件,可能会先敲个 do 看下有没对应功能方法的联想。所以个人建议不要增加太多个人的命名规则,多参考学习一下官方库的命名和用法。

最终方案

上述的经验主要是分享给一些自己有在写 Kotlin 工具类的小伙伴,而更多的人是不太会写的,所以这里分享一个我个人打磨了很久的 Kotlin 工具类库 —— Longan

为什么叫 Longan ?个人想用个水果名来作为库名,最初想到的是 Guava (石榴),感觉非常合适,但是发现有一个谷歌的同名库,所以换了个也是多子的水果 Longan (龙眼)。

添加依赖:

allprojects {
    repositories {
        // ...
        maven { url 'https://www.jitpack.io' }
    }
}
复制代码
dependencies {
    implementation 'com.github.DylanCaiCoding.Longan:longan:1.0.0'
    // 可选
    implementation 'com.github.DylanCaiCoding.Longan:longan-design:1.0.0'
}
复制代码

保留和改进了一些 Anko 好用的用法,例如:

startActivity<SomeOtherActivity>("id" to 5)
logDebug(5)
toast("Hi there!")
snackbar(R.string.message)
alert("Hi, I'm Roy", "Have you tried turning it off and on again?")
复制代码

还有很多开发常用的功能,比如下面的一些用法:

在需要 Context 或 Activity 的时候,可直接获取 applicationtopActivity 属性。

用较少的代码实现 TabLayout + ViewPager2 的自定义样式的底部导航栏:

private val titleList = listOf(R.string.home, R.string.shop, R.string.mine)
private val iconList = listOf(
  R.drawable.bottom_tab_home_selector
  R.drawable.bottom_tab_shop_selector,
  R.drawable.bottom_tab_mine_selector
)

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  ...
  viewPager2.adapter = FragmentStateAdapter(HomeFragment(), ShopFragment(), MineFragment())
  tabLayout.setupWithViewPager2(viewPager2, enableScroll = false) { tab, position ->
    tab.setCustomView(R.layout.layout_bottom_tab) {
      findViewById<TextView>(R.id.tv_title).setText(titleList[position])
      findViewById<ImageView>(R.id.iv_icon).apply {
        setImageResource(iconList[position])
        contentDescription = getString(titleList[position])
      }
    }
  }
}
复制代码

创建带参数的 Fragment,在 Fragment 内通过属性委托获取参数:

class SomeFragment : Fragment() {
  private val viewModel: SomeViewModel by viewModels()
  private val id: String by safeArguments(KEY_ID)

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    //...
    viewModel.loadData(id)
  }
  
  companion object {
    fun newInstance(id: String) = SomeFragment().withArguments(KEY_ID to id)
  }
}
复制代码
val fragment = SomeFragment.newInstance(id)
复制代码

一行代码实现双击返回键退出 App 或者点击返回键不退出 App 回到桌面:

pressBackTwiceToExitApp("再次点击退出应用")
// pressBackToNotExitApp()
复制代码

实现沉浸式状态栏,并且给标题栏的顶边距增加状态栏高度,可以适配刘海水滴屏:

immerseStatusBar()
toolbar.addStatusBarHeightToMarginTop()
// toolbar.addStatusBarHeightToPaddingTop()
复制代码

快速实现获取验证码的倒计时:

btnSendCode.startCountDown(this,
  onTick = {
    text = "${it}秒"
  },
  onFinish = {
    text = "获取验证码"
  })
复制代码

设置按钮在输入框有内容时才能点击:

btnLogin.enableWhenOtherTextNotEmpty(edtAccount, edtPwd)
复制代码

点击事件可以设置的点击间隔,防止一段时间内重复点击:

btnLogin.doOnClick(clickIntervals = 500) { 
  // ...
}
复制代码

简化自定义控件获取自定义属性:

withStyledAttrs(attrs, R.styleable.CustomView) {
  textSize = getDimension(R.styleable.CustomView_textSize, 12.sp)
  textColor = getColor(R.styleable.CustomView_textColor, getCompatColor(R.color.text_normal))
  icon = getDrawable(R.styleable.CustomView_icon) ?: getCompatDrawable(R.drawable.default_icon)
  iconSize = getDimension(R.styleable.CustomView_iconSize, 30.dp)
}
复制代码

自定义控件绘制居中或者垂直居中的文字:

canvas.drawCenterText(text, centerX, centerY, paint)
canvas.drawCenterVerticalText(text, centerX, centerY, paint)
复制代码

切换到主线程,用法与 thread {...} 保持了统一:

mainThread { 
  // ...
}
复制代码

监听生命周期操作:

lifecycleOwner.doOnLifecycle(
  onCreate = {
    // ...
  },
  onDestroy = {
    // ...
  }
)
复制代码

在 RecyclerView 数据为空的时候自动显示一个空布局:

recyclerView.setEmptyView(this, emptyView)
复制代码

RecyclerView 的 smoothScrollToPosition() 方法是滑动到 item 可见,如果从上往下滑会停在底部,一般不符合需求。所以增加了个始终滑动到顶部位置的扩展方法。

recyclerView.smoothScrollToStartPosition(position)
复制代码

每次判断 TextView 文本是否不为空要写 textView.text.toString().isNotEmpty() 特别长,对此进行了简化:

if (textView.isTextNotEmpty()) {
  // ...
}
复制代码

消息事件传递推荐 KunMinX 大佬的方案,用共享 ViewModel 持有的 LiveData 进行分发,避免消息推送难以溯源、消息同步不可靠不一致等问题。由于 LiveData 存在依赖倒灌的问题,一般会自行封装 EventLiveData 用于事件的场景。但是不考虑 Java 的话,直接用协程的 SharedFlow 就行。

class SharedViewModel : ViewModel() {
  val saveNameEvent = MutableSharedFlow<String>()
}
复制代码

通过 by applicationViewModels() 获取 Application 级别的 ViewModel,实现共享 ViewModel:

private val sharedViewModel: SharedViewModel by applicationViewModels()

// 发送事件
sharedViewModel.saveNameEvent.tryEmit(name)

// 监听事件,提供了类似 LiveData 的 observe 用法,简化 collect 的代码
sharedViewModel.saveNameEvent.launchAndCollectIn(this) {
  finish()
}
复制代码

还有很多好用的 API,比如 Android 10 分区存储适配需要增删查改媒体文件的 uri,能简化很多代码,这里就不一一介绍了。更多的用法请查看 GitHub目前已有超过 300 个常用方法或属性,可以大大提高开发效率

个人会长期维护,有任何问题都可以提 issues,我会尽快去处理。有什么想要的功能也可以提。

往期讲解封装的文章

总结

本文讲了 Top-Level、扩展、高阶函数、委托等一些 Kotlin 特性的使用建议,还有分享了一些封装工具类的经验。在最后分享了个人打磨了非常久的 Kotlin 工具类库 —— Longan,已有超过 300 个常用方法或属性,能有效提高开发效率,推荐用 Kotlin 开发的小伙伴来使用一下。

如果您觉得有帮助的话,希望能点个 star 支持一下哟 ~ 我后面会分享更多封装相关的文章给大家。

文章分类
Android
文章标签