優雅地結合 Kotlin 特性封裝工具類

語言: CN / TW / HK

theme: smartblue

本文已參與「掘力星計劃」,贏取創作大禮包,挑戰創作激勵金。

前言

在 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 去呼叫靜態方法都是多此一舉,明明寫成頂級屬性或頂級方法會更好用。

這雖然是一個很簡單的特性,但是也有地方要注意一下,就是命名要把功能描述清楚,個人認為很重要。比如之前寫了好一個沉浸式狀態列的功能,用法如下:

kotlin StatusBarUtils.immerse(this)

把方法移到類的外面就能變成頂級方法:

kotlin immerse(this)

有些人可能這樣改完就算了,但是這種情況是不好的,別人呼叫一個全域性的沉浸方法會很疑惑這是要沉浸什麼東西。之前能用“沉浸”的單詞作為方法名是因為工具類名也具有資訊,可以結合工具類的名稱推匯出是要沉浸狀態列。所以最好改成:

kotlin immerseStatusBar(this)

頂級方法或頂級屬性的命名要把功能描述清楚,因為這是能全域性呼叫的,不要只是單純地把原有 Java 工具類的類名給去掉。

拓展

Kotlin 可以很方便的擴充套件一個已經存在的類,為它新增額外的方法或屬性,無需繼承類或者使用裝飾者模式。

我們可以進一步優化上面沉浸狀態列的用法,把方法改成 Activity 的拓展方法。在方法前面增加一個接收者:

kotlin fun Activity.immerseStatusBar() { ... }

在方法內能用 this 獲取到 Activity 物件,所以原本的 Activity 引數就可以去掉了。這樣就可以在 Activity 呼叫:

```kotlin class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // ... immerseStatusBar() } } ```

Java 想實現這個用法,需要在 Activity 基類裡寫一個 immerseStatusBar() 方法,而 Kotlin 能直接用拓展實現,無需寫基類。

其實這也是個很簡單的特性,不過個人也有些建議給到大家:

  • 給 Any 或者常見的基礎資料型別進行擴充套件要慎重。
  • 用法儘量符合原來的使用習慣或直覺,不要差異過大。

什麼意思呢?通過拓展能玩出一些騷操作,但並不是什麼功能都合適。比如我剛開始接觸拓展時,什麼功能都想用拓展來封裝,看到列印日誌要傳兩個引數挺麻煩的,就用拓展函式來減少一個引數,給 String 增加一個列印的拓展方法:

kotlin "Downloaded progress is $progress".logd("http")

用法確實很騷,但是用了一段時間後覺得並不好用。用法與原來的列印日誌用法差異過大,寫得很彆扭。程式碼閱讀性變差了,要讀到末尾才知道是列印日誌,String 比較長的話可能沒反應過來這行是用來列印日誌的。而且在呼叫字串的方法時會彈出一個很讓人疑惑的程式碼提示。

還有看過別人給 Int 增加一個擴充套件屬性 drawableRes 獲取 Drawable,也是有類似的問題。

kotlin val drawable = R.drawable.ic_back_icon_black.drawableRes

這兩個例子在功能上都是沒問題的,但是用法差異太大會降低程式碼閱讀性,需要不少時間來適應。還給常見的型別增加了奇怪的程式碼提示,個人是不提倡的。用法騷並不代表著好用,不要為了用語法糖而用語法糖。

當然也有提倡的騷用法,比如給 Int 增加 dp 屬性,將 dp 轉為 px,用法如下:

kotlin paint.strokeWidth = 1.dp

雖然用法也是很大差異,但是符合直覺。我們讀這行程式碼能很容易想到是給屬性設定了 1 dp 的長度,程式碼可讀性反而更好了,這種用法是提倡的。

高階函式

高階函式是將函式用作引數或返回值的函式。後續會專門寫篇部落格講清楚高階函式的本質,這裡就不過多篇幅介紹怎麼去用了,來分享一個使用場景。多數人用高階函式是用於事件回撥,其實高階函式還能很方便地實現 DSL 用法。比如 Anko Layout 的 DSL:

kotlin verticalLayout { editText() button("Say Hello") { onClick { toast("Hello, ${name.text}!") } } }

這樣的 DSL 用法比鏈式呼叫舒服一些,而且能分層級,這是鏈式呼叫不好實現的。

那要怎麼運用呢?其實有可選的配置都是可以考慮使用的,最常見的是建造者模式,比如我們很熟悉的 Glide:

kotlin Glide.with(context) .load(url) .placeholder(placeholder) .fitCenter() .into(imageView)

我們稍微來封裝一下:

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

就這麼簡單地封裝就可以把鏈式呼叫轉為 DSL 用法。

kotlin imageView.load(url) { placeholder(placeholder) fitCenter() }

這樣用法就和 Coil 一樣了,不過還有些黃色警告需要處理,所以個人建議直接用 Coil。DSL 用法比鏈式呼叫更簡潔舒服一點,還能實現多級巢狀。

屬性委託

屬性委託是通過 by 關鍵字將屬性的 get、set 方法委託給 by 後面的表示式。比如:

kotlin private val viewModel: LoginViewModel by viewModels()

這是官方的 ViewModel 委託用法,獲取 viewModel 屬性時會通過 ViewModelProvider 去獲得 ViewModel 例項。使用委託後我們不用管如何獲得 ViewModel 了,可以專注於寫邏輯程式碼。

怎麼去寫委託需要些篇幅來講,後續會專門寫篇部落格。這裡講一下運用場景,有的人可能學習過屬性委託,但是不知道在哪裡使用。其實我們在通過某種方式獲取或設定屬性時,就可以考慮一下使用屬性委託合不合適。比如通過 intent 獲取傳遞的值:

```kotlin private var id: String? = null

override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val id = intent.getStringExtra("id") } ```

這裡可以通過屬性委託來簡化程式碼:

kotiln private val id: String? by intentExtras("id")

屬性委託能讓我們不用管如何獲得和設定屬性,程式碼更加簡潔,是個不錯的語法糖,可以多思考一下是否適合用屬性委託。

其它經驗

不重複造輪子

比如顯示隱藏需要呼叫 view.visibily = View.GONE 略顯繁瑣的,所以有些人會封裝拓展函式 view.visible() view.invisible()view.gone() 快速實現顯示隱藏。

用起來確實比之前方便了一些,但是還有優化空間,顯示隱藏經常是有個判斷操作的,比如:

kotlin if (isShowed) { view.visible() } else { view.gone() }

每次都要這麼判斷稍顯麻煩,所以更優地封裝方式是增加一個 view.isVisible 的 Boolean 值的擴充套件屬性,這樣上面的程式碼就會優化成下面的寫法:

kotlin view.isVisible = isShowed

這個擴充套件屬性不僅能用於修改顯示隱藏狀態,還能判斷當前是否顯示在佈局上,用起來更加方便。

不過在封裝完呼叫該拓展屬性時,你會發現有重名的屬性需要選擇用哪一個,仔細一看原來官方的 core-ktx 庫已經實現這個擴充套件屬性,我們沒必要再重複造輪子。

所以封裝工具類之前最好先了解一下 Android 官方 ktx 庫和 Kotlin 的標準庫有沒實現相同的功能,我們封裝的工具類的定位應該是對沒有的功能進行補充。 重複造輪子沒有意義,而且造出來的輪子可能還不如官方的。

命名建議

上面說了我們應該是補充官方庫沒有的功能,那麼設計用法時也建議參考一下官方庫的命名和用法。

比如帶引數的建立操作,官方通常會用 listOf()mapOf()xxxOf() 的命名,建議與官方統一,不推薦用 createXXX() 或者 newXXX() 等命名。

還有監聽事件的方法命名有些人喜歡命名為 onXXX,比如:

kotlin btnLogin.onClick { // ... }

這樣直接用介詞開頭挺奇怪的,一般方法名是動詞開頭。所以個人建議參考官方的命名 doOnXXX

kotlin view.doOnAttach { // ... }

與官方庫的命名規則進行統一的好處是不容易產生歧義,而且別人可能會根據以往的使用習慣,去猜想你的工具類會不會有某個功能。比如想看下有沒有某個監聽事件,可能會先敲個 do 看下有沒對應功能方法的聯想。所以個人建議不要增加太多個人的命名規則,多參考學習一下官方庫的命名和用法。

最終方案

上述的經驗主要是分享給一些自己有在寫 Kotlin 工具類的小夥伴,而更多的人是不太會寫的,所以這裡分享一個我個人打磨了很久的 Kotlin 工具類庫 —— Longan

為什麼叫 Longan ?個人想用個水果名來作為庫名,最初想到的是 Guava (石榴),感覺非常合適,但是發現有一個谷歌的同名庫,所以換了個也是多子的水果 Longan (龍眼)。

新增依賴:

groovy allprojects { repositories { // ... maven { url 'http://www.jitpack.io' } } }

groovy dependencies { implementation 'com.github.DylanCaiCoding.Longan:longan:1.0.0' // 可選 implementation 'com.github.DylanCaiCoding.Longan:longan-design:1.0.0' }

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

kotlin 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 的自定義樣式的底部導航欄:

```kotlin 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(R.id.tv_title).setText(titleList[position]) findViewById(R.id.iv_icon).apply { setImageResource(iconList[position]) contentDescription = getString(titleList[position]) } } } } ```

建立帶引數的 Fragment,在 Fragment 內通過屬性委託獲取引數:

```kotlin 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) } } ```

kotlin val fragment = SomeFragment.newInstance(id)

一行程式碼實現雙擊返回鍵退出 App 或者點選返回鍵不退出 App 回到桌面:

kotlin pressBackTwiceToExitApp("再次點選退出應用") // pressBackToNotExitApp()

實現沉浸式狀態列,並且給標題欄的頂邊距增加狀態列高度,可以適配劉海水滴屏:

kotlin immerseStatusBar() toolbar.addStatusBarHeightToMarginTop() // toolbar.addStatusBarHeightToPaddingTop()

快速實現獲取驗證碼的倒計時:

kotlin btnSendCode.startCountDown(this, onTick = { text = "${it}秒" }, onFinish = { text = "獲取驗證碼" })

設定按鈕在某些輸入框都有內容時才能點選:

kotlin btnLogin.enableWhenOtherTextNotEmpty(edtAccount, edtPwd)

點選事件可以設定的點選間隔,防止一段時間內重複點選:

kotlin btnLogin.doOnClick(clickIntervals = 500) { // ... }

自定義控制元件獲取自定義屬性的程式碼比較多,進行了簡化:

kotlin 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) }

自定義控制元件繪製居中或者垂直居中的文字:

kotlin canvas.drawCenterText(text, centerX, centerY, paint) canvas.drawCenterVerticalText(text, centerX, centerY, paint)

切換到主執行緒,用法與 thread {...} 保持了統一:

kotlin mainThread { // ... }

監聽生命週期操作:

kotlin lifecycleOwner.doOnLifecycle( onCreate = { // ... }, onDestroy = { // ... } )

在 RecyclerView 資料為空的時候自動顯示一個空佈局:

kotlin recyclerView.setEmptyView(this, emptyView)

RecyclerView 的 smoothScrollToPosition() 方法是滑動到 item 可見,如果從上往下滑會停在底部,一般不符合需求。所以增加了個始終滑動到頂部位置的拓展方法。

kotlin recyclerView.smoothScrollToStartPosition(position)

每次判斷 TextView 文字是否不為空要寫 textView.text.toString().isNotEmpty() 特別長,對此進行了簡化:

kotlin if (textView.isTextNotEmpty()) { // ... }

訊息事件傳遞推薦 KunMinX 大佬的方案,用共享 ViewModel 持有的 LiveData 進行分發,避免訊息推送難以溯源、訊息同步不可靠不一致等問題。由於 LiveData 存在依賴倒灌的問題,一般會自行封裝 EventLiveData 用於事件的場景。但是不考慮 Java 的話,直接用協程的 SharedFlow 就行。

kotlin class SharedViewModel : ViewModel() { val saveNameEvent = MutableSharedFlow<String>() }

通過 by applicationViewModels() 獲取 Application 級別的 ViewModel,實現共享 ViewModel:

```kotlin 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 支援一下喲 ~ 我後面會分享更多封裝相關的文章給大家。