用 Kotlin 开发 Android 项目是一种什么样的感受?(二)

1,812 阅读11分钟

前言

前面我已经写了一篇名为《用 Kotlin 开发 Android 项目是一种什么样的感受?》的文章。文中多数提到的还是 Kotlin 语言本身的特点,而 Kotlin 对于 Android 的一些特殊支持我没有收录在内,已经有朋友给我提出了建议。于是在前文的基础上,这一次我们或许会说的更详细,Kotlin 开发 Android 究竟还有一些什么让人深感愉悦之处。

正文

1.向 findViewById 说 NO

不同于 JAVA 中,在 Kotlin 中 findViewById 本身就简化了很多,这得益于 Kotlin 的类型推断以及转型语法后置:

val onlyTv = findViewById(R.id.onlyTv) as TextView

很简洁,但若仅仅是这样,想必大家会喷死我:就这么点差距也拿出来搞事?

当然不是。在官方库 anko 的支持下,这事又有了很多变化。 例如

val onlyTv = find<TextView>(R.id.onlyTv)
val onlyTv: TextView = find(R.id.onlyTv)

肯定有人会问:find 是个什么鬼? 让我们点过去看看 find 的源码:

inline fun <reified T : View> Activity.find(id: Int): T = findViewById(id) as T

忽略掉其他细节,原来和我们上面第一种写法没差别嘛,不就是用一个扩展方法给 Activity 加了这么一个方法,帮我们写了 findViewById,再帮我们转型了一下嘛。

其实 Kotlin 中还有很多令人乍舌的实现其实都是在一些基础特性的组合之上实现的,比如上面的 find 方法我结合一下原生提供的 lazy 代理:

class MainActivity : AppCompatActivity() {

    val onlyTv by lazy { find<TextView>(R.id.onlyTv) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        onlyTv.text = "test"
    }
}

以上代码虽是笔者临时异想天开的一个玩法,但是经过测试毫无问题。 也就是说,我可以这样子把 view 的声明和 findViewById 一同放在声明的地方。

而且这还只是用原生提供的 lazy 代理,如果愿意,我们完全可以达成这样的效果:

val onlyTv by myOwnDelegate<TextView>(R.id.onlyTv)

如果我们给 myOwnDelegate 取一个名字呢?

val onlyTv by find<TextView>(R.id.onlyTv)
val onlyTv by findView<TextView>(R.id.onlyTv)
val onlyTv by findViewById<TextView>(R.id.onlyTv)

挺棒的对吧?我还要啥依(zi)赖(xing)注(che)入?

有的时候,还真的要看我们脑洞够不够大。正如你以为这就是我想说的全部(其实明明是我自己写到这里以为这一节应该结束了) 如果我告诉你,其实你原本一句代码都不用写,你信吗?

此处为了作为证据,我还是上截图吧:

代码截图

毫无 onlyTv 声明痕迹,也不可能从 AppCompatActivity 继承而来。而且当你试图 command/ctrl + 左键点击 onlyTv 想要查看 onlyTv 的来源的时候,你会发现你跳到了 activity_main 的布局文件:

屏幕快照 2017-04-05 下午12.10.49.png

也许眼尖的朋友已经发现了,唯一的真相就是:

import kotlinx.android.synthetic.main.activity_main.*

请恕在下能力有限,暂时无法为大家讲解其中缘由。但可以确定的就是,在 anko 的帮助下,你只需要根据布局的 id 写一句 import 代码,然后你就可以把布局中的 id 作为 view 对象的名称直接进行使用。不仅 activity 中可以这样玩,你甚至可以 viewA.viewB.viewC,所以大可不必担心 adapter 中应当怎么写。

没有 findViewById,也就减少了空指针;没有 cast,则几乎不会有类型转换异常。

PS.也许有的朋友会发现这和 Google 出品的 databinding 实在是有异曲同工之妙,那如果我告诉你,databinding 库本身就有对 kotlin 的依赖呢?

2.简单粗暴的 startActivity

我们原本大都是这样子来做 Activity 跳转的:

Intent intent = new Intent(LoginActivity.this, MainActivity.class);
startActivity(intent);

为了 startActivity,我不得不 new 一个 Intent 出来,特别是当我要传递参数的时候:

Intent intent = new Intent(LoginActivity.this, MainActivity.class);
intent.putExtra("name", "张三");
intent.putExtra("age", 27);
startActivity(intent);

不知道大家有木有累觉不爱?

在 anko 的帮助下,startActivity 是这样子的:

startActivity<MainActivity>()
startActivity<MainActivity>("name" to "张三", "age" to 27)
startActivityForResult<MainActivity>(101, "name" to "张三", "age" to 27)

无参情况下,只需要在调用 startActivity 的时候加一个 Activity 的 Class 泛型来告知要到哪去。有参也好说,这个方法支持你传入 vararg params: Pair<String, Any>

有没有觉得代码写起来、读起来流畅了许多?

3.玲珑小巧的 toast

JAVA 中写一个 toast 大概是这样子的:

Toast.makeText(context, "this is a toast", Toast.LENGTH_SHORT).show();

以上代码纯属手打,如有错误请各位指正。 不得不说真的是又臭又长,虽然确实是有很多考量在里面,但是对于使用来说实在是太不便利了,而且还很容易忘记最后一个 show()。我敢说没有任何一个一年以上的 Android 开发者会不去封装一个 ToastUtil 的。

封装之后大概会是这样:

ToastUtil.showShort(context, "this is a toast");

如果处理一下 context 的问题,可以缩短成这样:

ToastUtil.showShort("this is a toast");

有那么一点极简的味道了对吧?

好了,是时候让我们看看 anko 是怎么做的了:

context.toast("this is a toast")

如果当前已经是在 context 上下文中(比如 activity):

toast("this is a toast")

如果你是想要一个长时间的 toast:

longToast("this is a toast")

没错,就是给 Context 类扩展了 toast 和 longToast 方法,用屁股想都知道里面干了什么。只是这样一来比任何工具类都来得更简洁更直观。

4.用 apply 方法进行数据组合

假设有如下 A、B、C 三个 class:

class A(val b: B)

class B(val c: C)

class C(val content: String)

可以看到,A 中有 B,B 中有 C。在实际开发的时候,我们有的时候难免会遇到比这个更复杂的数据,嵌套层级很深。这种时候,用 JAVA 初始化一个 A 类数据会变成一件非常痛苦的事情。例如:

C c = new C("content");
B b = new B(c);
A a = new A(b);

这还是 A、B、C 的关系很单纯的情况下,如果有大量数据进行组合,那么我们会需要初始化大量的对象进行赋值、修改等操作。如果我描述的不够清楚的话,大家不妨想一想用 JAVA 代码布局是一种什么样的感觉?

当然,在 JAVA 中也是有解决方案的,比如 Android 中常用的 Dialog,就用了 Builder 模式来进行相应配置。(说到这里,其实用 Builder 模式基本上也可以说是 JAVA 语言的 DSL)

但是在更为复杂的情况下,即便是有设计模式的帮助,也很难保证代码的可读性。那么 Kotlin 有什么好方法,或者说小技巧来解决这个问题吗?

Kotlin 中有一个名为 apply 的方法,它的源码是这样子的:

@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T { block(); return this }

没有 Kotlin 基础的小伙伴看到这里一定会有点晕。我们先忽略一部分细节,把关键的信息提取出来,再改改格式看看:

public fun <T> T.apply(block: T.() -> Unit): T {
    block()
    return this
}

1.首先,我们可以看出 T 是一个泛型,而且后面没有给 T 增加约束条件,那么这里的 T 可以理解为:我这是在给所有类扩展一个名为『apply』的方法;

2.第一行最后的: T 表明,我最终是要返回一个 T 类。我们也可以看到方法内部最后的 return this 也能说明,其实最后我就是要返回调用方法的这个对象自身;

3.在 return this 之前,我执行了一句 block(),这意味着 block 本身一定是一个方法。我们可以看到,apply 方法接收的 block 参数的类型有点特殊,不是 String 也不是其他什么明确的类型,而是 T.() -> Unit

4.T.() -> Unit 表示的意思是:这是一个 ①上下文在 T 对象中,②返回一个 Unit 类对象的方法。由于 Unit 和 JAVA 中的 Void 一致,所以可以理解为不需要返回值。那么这里的 block 的意义就清晰起来了:一个执行在 T,即调用 apply 方法的对象自身当中,又不需要返回值的方法。

有了上面的解析,我们再来看一下这句代码:

val textView = TextView(context).apply {
    text = "这是文本内容"
    textSize = 16f
}

这句代码就是初始化了一个 TextView,并且在将它赋值给 textView 之前,将自己的文本、字体大小修改了。

或许你会觉得这和 JAVA 比起来并没有什么优势。别着急,我们慢慢来:

layout.addView(TextView(context).apply {
    text = "这是文本内容"
    textSize = 16f
})

这样又如何呢?我并不需要声明一个变量或者常量来持有这个对象才能去做修改操作。

上面的A、B、C 问题用 Kotlin 来实现是可以这么写的:

val a = A().apply {
    b = B().apply {
        c = C("content")
    }
}

我只声明了一个 a 对象,然后初始化了一个 A,在这个初始化的对象中先给 B 赋值,然后再提交给了 a。B 中的 C 也是如此。当组合变得复杂的时候,我也能保持我的可读性:

val a = A().apply {
    b = B().apply {
        c = C("content")
    }

    d = D().apply {
        b = B().apply {
            c = C("test")
        }

        e = E("test")
    }
}

上面的代码用 JAVA 实现会是如何一番场景?反正我是想一想就已经晕了。说到底,这个小技巧也就是 ①扩展方法 + ②高阶函数 两个特性组合在一起实现的效果。

5.利用高阶函数搞事情

先看代码

inline fun debug(code: () -> Unit) {
    if (BuildConfig.DEBUG) {
        code()
    }
}
...
// Application 中
debug {
    Timber.plant(Timber.DebugTree())
}

上述代码是先定义了一个全局的名为 debug 的方法,这个方法接收一个方法作为参数,命名为 code。然后在方法体内部,我先判断当前是不是 DEBUG 版本,如果是,再调用传入的 code 方法。

而后我们在 Application 中,debug 方法就成为了依据条件执行代码的关键字。仅当 DEBUG 版本的时候,我才初始化 Timber 这个日志库。

如果这还不够体现有点的话,那么可以再看看下面一段:

supportsLollipop {
    window.statusBarColor = Color.TRANSPARENT
    window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
}

当系统版本在 Lollipop 之上时才去做沉浸式状态栏。系统 api 经常会有版本的限制,相对于一个 supportsLollipop 关键字, 我想一定不是所有人都希望每次都去写:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    // do something
}

诸如此类的场景和可以自创的 关键字/代码块 还有很多。 例如:

inline fun handleException(code : () -> Unit) {
    try {
        code()
    } catch (e : Exception) {
        e.printStackTrace()
    }
}
...
handleException {
     println(Integer.parseInt("这明显不是数字"))
}

虽然大都可以用 if(xxxxUtil.isxxxx()) 来凑合,但是既然有了更好的方案,那还何必凑合呢?

6.用扩展方法替代工具类

曾几何时,我做字符串判断的时候一定会写一个工具类,在这个工具类里充斥着各种各样的判断方法。而在 Kotlin 中,可以用扩展方法来替代。下面是我项目中 String 扩展方法的一部分:

fun String.isName(): Boolean {
    if (isEmpty() || length > 10 || contains(" ")) {
        return false
    }

    val reg = Regex("^[a-zA-Z0-9\u4e00-\u9fa5]+$")
    return reg.matches(this)
}

fun String.isPassword(): Boolean {
    return length in 6..12
}

fun String.isNumber(): Boolean {
    val regEx = "^-?[0-9]+$"
    val pat = Pattern.compile(regEx)
    val mat = pat.matcher(this)

    return mat.find()
}
...

println("张三".isName())
println("123abc".isPassword())
println("123456".isNumber())
7.自动 getter、setter 使得代码更精简

以 TextView 举例,JAVA 代码中获取文本、设置文本的代码分别为:

String text = textView.getText().toString();
textView.setText("new text");

Kotlin 中是这样写的:

val text = textView.text
textView.text = "new text"

如果 TextView 是一个原生的 Kotlin class,那么是没有 getText 和 setText 两个方法的,而是一个 text 属性。尽管此处的TextView 是 JAVA class,源码中有getText 和 setText 两个方法,Kotlin 也做了类似映射的处理。当这个 text 属性在等号右边的时候,就是在提取 text 属性(此处映射为 getText);当在等号左边的时候,就是在赋值(setText)。

说到这里我又想起了上一篇文章中提到的 Preference 代理,其实也有一定关联,那就是当一个属性在等号左边和右边的时候,不同于 JAVA 中一定是赋值操作,在 Kotlin 中则有可能会触发一些别的。

未完待续...

补充:

翻看之前的项目,发现有如下代码可做对比:

构建并显示 BottomSheet
Builder 版

BottomSheet.Builder(this@ShareActivity, R.style.ShareSheetStyle)
        .sheet(999, R.drawable.share_circle,  R.string.wXSceneTimeline)
        .sheet(998, R.drawable.share_freind,  R.string.wXSceneSession)
        .listener { _, id ->
            shareTo(bitmap, target = when(id) {
                999 -> SendMessageToWX.Req.WXSceneTimeline
                998 -> SendMessageToWX.Req.WXSceneSession
                else -> throw Exception("it can not happen")
            })
        }
        .build()
        .show()
DSL 版

showBottomSheet {
    style = R.style.ShareSheetStyle

    sheet {
        icon = R.drawable.share_circle
        text = R.string.wXSceneTimeline

        selected {
            shareTo(bitmap, SendMessageToWX.Req.WXSceneTimeline)
        }
    }

    sheet {
        icon = R.drawable.share_freind
        text = R.string.wXSceneSession

        selected {
            shareTo(bitmap, SendMessageToWX.Req.WXSceneTimeline)
        }
    }
}
apply 构建数据实例(微信分享)
普通版

val obj = WXImageObject(bitmap)
val thumb = ......
bitmap.recycle()

val msg = WXMediaMessage()
msg.mediaObject = obj
msg.thumbData = thumb

val req = SendMessageToWX.Req()
req.transaction = "share"
req.scene = target
req.message = msg

WxObject.api.sendReq(req)

DSL 版

WxObject.api.sendReq(
        SendMessageToWX.Req().apply {
            transaction = "share"
            scene = target
            message = WXMediaMessage().apply {
                mediaObject = WXImageObject(bitmap)
                thumbData = ......
                bitmap.recycle()
            }
        }
)

要是有人能只看普通版的,3秒之内看清结构关系,那一定是天才。。