用 Kotlin 開發 Android 項目是一種什么樣的感受(二)
前言
前面我已經寫了一篇名為《用 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(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 的布局文件:

也許眼尖的朋友已經發現了,唯一的真相就是:
- 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
有沒有覺得代碼寫起來、讀起來流暢了許多?
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
- }
- 首先,我們可以看出 T 是一個泛型,而且后面沒有給 T 增加約束條件,那么這里的 T 可以理解為:我這是在給所有類擴展一個名為『apply』的方法;
- ***行***的: T 表明,我最終是要返回一個 T 類。我們也可以看到方法內部***的 return this 也能說明,其實***我就是要返回調用方法的這個對象自身;
- 在 return this 之前,我執行了一句 block(),這意味著 block 本身一定是一個方法。我們可以看到,apply 方法接收的 block 參數的類型有點特殊,不是 String 也不是其他什么明確的類型,而是 T.() -> Unit ;
- 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秒之內看清結構關系,那一定是天才。。