用 Kotlin 開發 Android 專案是一種什麼樣的感受?(二)

neverwoods發表於2017-12-23

前言

前面我已經寫了一篇名為《用 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秒之內看清結構關係,那一定是天才。。

相關文章