Android 架構元件 - 讓天下沒有難做的 App

liangfei發表於2019-03-25

Google 為了幫助 Android 開發者更快更好地開發 App,推出了一系列元件,這些元件被打包成了一個整體,稱作 Android Jetpack,它包含的元件如下圖所示:


Jetpack.png


老的 support 包被整合進了 Jetpack,例如上圖 Foundation 模組的 AppCompat,整合進去之後,包名做了一下修改,全部以 androidx 開頭。Android Studio 提供的遷移工具(Refactor > Migrate to AndroidX)可以將原始碼中的舊包名替換成新的,但是如果 Maven 依賴的產物還未遷移到 AndroidX 的話,還需要配置一個工具—— Jetifier,只需要在 build.gradle 中加上兩行配置即可:


android.useAndroidX=true
android.enableJetifier=true複製程式碼


Jetfier 會在編譯階段直接修改依賴產物的位元組碼,簡單粗暴。


架構大圖

Jetpack 不屬於 Android Framework,不是 Android 開發的必需品,它只是應用層開發的一種輔助手段,幫我們解決了一些常見問題,比如版本相容、API 易用性、生命週期管理等。其中 Architecture 部分的元件(Android Architecture Components,以下簡稱 AAC)組合起來形成了一套完整的架構解決方案,在沒有更好的方案被髮明出來之前,我們姑且把 AAC 當做 Android 架構領域的最佳實踐,它的出現一定程度上避免了很多不必要的輪子。


官方給出的架構指導非常明確地表達出了每個架構元件的位置:


image.png


這張圖背後隱含了三大設計思想:

  • 關注點分離(SOC / Separation Of Concerns)
  • 資料驅動 UI(Reactive
  • 唯一真相源(SSOC / Single Source Of Truth)


SOC 具體到工程實踐中就是分層合理,單層的職責越明確,對上下游的依賴越清晰就意味著它的結構更穩定,也

更可測(testable)。一個 App 從全域性來看,可以劃分為三部分:首先是 UI Controller 層,包含 Activity 和 Fragment;其次是 ViewModel 層,既可以做 MVVM 的 VM、MVP 的 P,也可以做 UI 的資料適配,這一層可以實現資料驅動 UI;最後是 Repository 層,它作為 SSOC,是一個 Facade 模式,對上層遮蔽了資料的來源,可以來自 local,也是來自 remote,資料持久化策略向上透明。


一張架構藍圖,三大設計原則,接下來深入細節,看看元件之間如何配合才能實現這個架構。


Lifecycle

與 React/Vue 或者 iOS 相比,Android 的生命週期都比較複雜,如果要監聽生命週期,一般情況下只能覆寫 Activity / Fragment 的回撥方法(onCreate、onResume、onPause、onDestroy 等),樣板程式碼少不了,可維護性也變差。


如果要對生命週期進行簡化,可以抽象成一個圖,表示狀態,表示事件:


image.png


Lifecycle 負責處理這些點(states)和線(events),Activity / Fragment 是 LifecycleOwner,監聽者則是 LifecycleObserver,一個非常清晰的觀察者模式。


class MyObserver : LifecycleObserver {

    @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
    fun connectListener() {
        ...
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
    fun disconnectListener() {
        ...
    }
}複製程式碼


如果我們的元件需要強繫結宣告週期,那麼只需要藉助 Lifecycle 去監聽生命週期的狀態和事件即可,再也不用覆寫各種回撥方法了。下面將要講到的 LiveData 和 ViewModel 都是 Lifecycle-Aware Components,它們都用到了 Lifecycle。


Android 生命週期管理不當帶來的最大問題就是記憶體洩露,舉一個我們經常遇到的場景:一個非同步任務(比如網路請求)持有了 UI 元素的引用,只要任務沒有執行完,所有與這個 UI 元素有強引用關係的元素都沒法被 GC,如果這樣的場景多發生幾次,很可能會引起 OOM。


為了非同步物件引用的問題,最早我們使用 AsyncTask,任務執行在 worker thread,執行結果在主執行緒上發起回撥。AsyncTask 的致命缺點是不支援流式資料(stream),而且回撥巢狀太深(callback hell),與軟體質量衡量指標之一的 maintainable 背道而馳,不好用自然就會慢慢被淘汰。


後來我們開始使用 RxJava,響應式程式設計,宣告式寫法,再借助 retrolambda 這種 backport,即使當年 Android 只支援到 JDK7,我們依然可以利用各種 operator 寫出非常簡潔的程式碼,“filter map 我閉~著眼”。RxJava 不但完美解決了執行緒排程的問題,還為我們提供了 OO 之外的抽象——作用在流上的 lambda,基於函式的抽象。但是,即便完美如斯,生命週期的問題依然無法迴避,因為 Java 天生的侷限性,一個 lambda 無論偽造地再像高階函式,它本質上還是一個匿名內部類,這個匿名內部類依然持有對 outer class 例項的引用。於是我們必須通過 CompositeDisposable 來管理訂閱關係,發起非同步操作時記錄訂閱,離開頁面時取消訂閱,仍然需要覆寫 onDestory 或者 onPause 。


如果我們以 Repository 層為界把架構藍圖分為上下兩部分的話,上面的部分是資料展示,下面的部分是資料獲取,資料獲取部分因為要請求 Remote 資料,必然會依賴到執行緒排程,而資料展示必然執行在 UI 執行緒,與生命週期強相關,這個時候就需要 LiveData 登場了。


LiveData

LiveData 也是一個觀察者模型,但是它是一個與 Lifecycle 繫結了的 Subject,也就是說,只有當 UI 元件處於 ACTIVE 狀態時,它的 Observer 才能收到訊息,否則會自動切斷訂閱關係,不用再像 RxJava 那樣通過 CompositeDisposable 來手動處理。


LiveData 的資料類似 EventBus 的 sticky event,不會被消費掉,只要有資料,它的 observer 就會收到通知。如果我們要把 LiveData 用作事件匯流排,還需要做一些定製,Github 上搜 SingleLiveEvent 可以找到原始碼實現。


我們沒法直接修改 LiveData 的 value,因為它是不可變的(immutable),可變(mutable)版本是 MutableLiveData,通過呼叫 setValue(主執行緒)或 postValue(非主執行緒)可以修改它的 value。如果我們對外暴露一個 LiveData,但是不希望外部可以改變它的值,可以用如下技巧實現:


private val _waveCode = MutableLiveData<String>()
val waveCode: LiveData<String> = _waveCode複製程式碼


內部用 MutableLiveData ,可以修改值,對外暴露成 LiveData 型別,只能獲取值,不能修改值。


LiveData 有一個實現了中介者模式的子類 —— MediatorLiveData,它可以把多個 LiveData 整合成一個,只要任何一個 LiveData 有資料變化,它的觀察者就會收到訊息:


 val liveData1 = ...
 val liveData2 = ...

 val liveDataMerger = MediatorLiveData<>();
 liveDataMerger.addSource(liveData1) { value -> liveDataMerger.setValue(value))
 liveDataMerger.addSource(liveData2) { value -> liveDataMerger.setValue(value))複製程式碼


綜上,我們彙總一下 LiveData 的使用場景:

  • LiveData - immutable 版本
  • MutableLiveData - mutable 版本
  • MediatorLiveData - 可彙總多個資料來源
  • SingleLiveEvent - 事件匯流排


LiveData 只儲存最新的資料,雖然用法類似 RxJava2 的 Flowable,但是它不支援背壓(backpressure),所以不是一個流(stream),利用 LiveDataReactiveStreams 我們可以實現 Flowable 和 LiveData 的互換。


如果把非同步獲取到的資料封裝成 Flowable,通過 toLiveData 方法轉換成 LiveData,既利用了 RxJava 的執行緒模型,還消除了 Flowable 與 UI Controller 生命週期的耦合關係,藉助 Data Binding 再把 LiveData 繫結到 xml UI 元素上,資料驅動 UI,妥妥的響應式。於是一幅如下模樣的資料流向圖就被勾勒了出來:


LiveData.png


圖中右上角的 Local Data 是 AAC 提供的另一個強大武器 —— ORM 框架 Room。


Room

資料庫作為資料持久層,其重要性不言而喻,當裝置處於離線狀態時,資料庫可用於快取資料;當多個 App 需要共享資料時,資料庫可以作為資料來源,但是基於原生 API 徒手寫 CRUD 實在是痛苦,雖然 Github 上出現了不少 ORM 框架,但是它們的易用性也不敢讓人恭維,直到 Room 出來之後,Android 程式設計師終於可以像 mybatis 那樣輕鬆地操縱資料庫了。


Room 是 SQLite 之上的應用抽象層,而 SQLite 是一個位於 Android Framework 層的記憶體型資料庫。雖然 Realm 也是一個優秀的資料庫,但是它並沒有內建於 Android 系統,所會增大 apk 的體積,使用 Room 則沒有這方面煩惱。


Room 的結構抽象得非常簡單,資料物件(表名 + 欄位)用 @Entity 註解來定義,資料訪問用 @Dao 來註解,db 本身則用 @Database 來定義,如果要支援複雜型別,可以定義 @TypeConverters,然後在編譯階段,apt 會根據這些註解生成程式碼。Room 與 App 其他部分的互動如下圖所示:


image.png


Entity 是一個資料實體,表示一條記錄,它的用法如下:


@Entity(tableName = "actors")
data class Actor(
        @PrimaryKey @ColumnInfo(name = "id")
        val actorId: String,
        val name: String,
        val birthday: Date?,
        val pictureUrl: String
)複製程式碼


Actor 是一個用 @Entity 註解的 data class,它會生成一個名字是 actors 的表,注意到有一個欄位是 @Date? ,但是 SQLite 本身不支援這種複雜型別(complex type),所以我們還需要寫一個可以轉換成基礎型別的轉換器:


class Converters {
    @TypeConverter
    fun timestampToDate(value: Long?) = value?.let { Date(it) }

    @TypeConverter
    fun dateToTimestamp(date: Date?) = date?.time
}複製程式碼


轉換器通過 @TypeConverters 可作用於 class、field、method、parameter,分別代表不同的作用域。比如作用在 @Database 類的上,那麼它的作用域就是 db 中出現的所有 @Dao@Entity


@Database(entities = [Actor::class], version = 1, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun actorDao(): ActorDao
}複製程式碼


程式碼出現的 ActorDao 定義了 CRUD 操作。用 @Dao 來註解,它既可以是一個介面,也可以是抽象類,用法如下:


@Dao
interface ActorDao {
    @Query("SELECT * FROM actors WHERE id = :actorId")
    fun getActor(actorId: String): LiveData<Actor>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertAll(actors: List<Actor>)
}複製程式碼


@Query 中的 SQL 語句可以直接引用方法引數,而且它的返回值可以是 LiveData 型別,也支援 Flowable 型別,也就是說,Room 原生支援響應式,這是對資料驅動最有利的支援,也是 Room 區別於其他 ORM 框架的顯著特徵。


至此,我們可以確定,無論資料來自 Remote 還是來自本地 DB,架構藍圖中的 Repository 對 ViewModel 提供的資料可以永遠是 LiveData 型別,接下來我們看一下 ViewModel 的妙用。


ViewModel

ViewModel 是一個多面手,因為它的生命週期比較長,可以跨越因為配置變動(configuration changed,比如螢幕翻轉)引起的 Activity 重建,因此 ViewModel 不能持有對 Activity / Fragment 的引用。

image.png


如果 ViewModel 中要用到 context 怎麼辦呢?沒關係,框架提供了一個 ViewModel 的子類 AndroidViewModel ,它在構造時需要傳入 Application 例項。


既然 ViewModel 與 UI Controller 無關,當然可以用作 MVP 的 Presenter 層提供 LiveData 給 View 層,因為 LiveData 繫結了 Lifecycle,所以不存在記憶體洩露的問題。除此之外,ViewModel 也可以用做 MVVM 模式的 VM 層,利用 Data Binding 直接把 ViewModel 的 LiveData 屬性繫結到 xml 元素上,xml 中宣告式的寫法避免了很多樣板程式碼,資料驅動 UI 的最後一步,我們只需要關注資料的變化即可,UI 的狀態會自動發生變化。


ViewModel 配合 Data Binding 的用法與 React 非常相似,ViewModel 例項相當於 state,xml 檔案就好比 render 函式,只要 state 資料發生變化,render 就會重新渲染 UI,但是 data binding 還有更強大的一點,它支援雙向繫結。舉個例子,UI 需要展示一個評論框,允許展示評論,也允許使用者修改,那麼我們可以直接把 EditText 雙向繫結到一個 LiveData 之上,只要使用者有輸入,我們就可以收到通知,完全不需要通過 Kotlin/Java 來操控 UI:


<TextInputEditText
    android:text="@={viewModel.commentText}" />複製程式碼


注意,如果要在 xml 中使用 LiveData,需要把 lifecycle owner 賦給 binding:


val binding: MainBinding = DataBindingUtil.setContentView(this, R.layout.main)
// Specify the current activity as the lifecycle owner.
binding.setLifecycleOwner(this)複製程式碼


因為 ViewModel 拿到的資料是 Repository 給的,可能不適用於 UI 元素,所以 ViewModel 還承擔了資料適配的工作,有時候我們需要彙總 repository 的多個返回值一次性給到 UI,那麼就可以使用 LiveData 的“操作符” Transformations.switchMap,用法可以認為等同於 Rx 的 flatMap;如果只想對 LiveData 的 value 做一些對映,可以使用 Transformations.map,目前 Transformations 只有這兩個操作符,因為不管 Kotlin 還是 Java8,都提供了很多宣告式的操作符,對流的支援都比較友好,而 LiveData 本身不是一個流,所以這兩個操作符足矣。


除了資料適配之外,ViewModel 還有一個強大的用法 —— Fragment 之間共享資料,這樣 ViewModel 又扮演了 FLUX 模式中的 store 這一角色,是多個頁面(fragment)之間唯一的資料出口。


Redux.png



ViewModel 的用法也非常簡單,通過 ViewModelProviders.of 可以獲取 ViewModel 例項:


val viewModel = ViewModelProviders.of(requireActivity(), factory)
        .get(ActorViewModel::class.java)複製程式碼


一通操作猛如虎之後,UI controller 層變得薄如蟬翼,它只做了一件事情,把資料從左手(ViewModel)倒給了右手(使用了 Data Binding 的 xml)。


如果把 ViewModel 作為 SSOC(唯一真相源),多個 Fragment 之間共享資料,再利用 SingleLiveEvent 做匯流排,一個 Activity 配多個 Fragment 的寫法就避免了 Activity 之間通過 Intent 傳遞資料的繁瑣。但是 Fragment 的堆疊管理一直是一個讓人頭疼的問題,AAC 的 Navigation 不但完美解決了這個問題,而且還提供視覺化的路由,只需拖拽一下就能生成型別安全的跳轉邏輯。


Navigation

Navigation 用一個(graph)來表示頁面間的路由關係,圖的節點(node)表示頁面,邊(edge)表示跳轉關係。例如下圖 8 個頁面的跳轉關係,一目瞭然:

image.png


頁面與頁面之間的連線叫 action,它可以配置進離場動畫(Animations),也可以配置出棧行為(Pop Behavior),還支援 Single Top 的啟動選項(Launch Options)。進離場動畫和啟動選項很好理解,出棧行為是一個比較強大的功能,action 箭頭所指的方向表示目標頁面入棧,箭頭的反方向則表示目標頁面出棧,而出棧的行為在 Navigation 編輯器中完全可控,我們可以指定要出棧到哪個頁面,甚至可以指定目標頁面是否也需要出棧:


image.png


針對頁面節點,還可以定義它要接收的引數(arguments),支援預設值,從此 Fragment 之間的引數傳遞變得非常直觀,非常安全。


看一下具體用法,首先在跳轉發起頁面,通過 apt 生成的跳轉函式傳入引數:

val actorId = getSelectedActorId()
val direction = ActorListFragmentDirections.showDetail(actorId)
findNavController().navigate(direction)複製程式碼


注意:如果使用 LiveData 或其他來觸發跳轉邏輯,navigate 會多次執行,從而導致 crash,所以要麼在 navigate 執行結束之後移除掉 observer,或者通過 AtomicBoolean 來控制只執行一次。


然後利用目標頁面生成的 *Args 獲取引數:

private val args: ActorDetailFragmentArgs by navArgs()複製程式碼


這裡的 navArgs 是一個擴充套件函式,利用了 Kotlin 的 ReadWriteProperty。


幾行程式碼就搞定了頁面之間的跳轉,而且還是視覺化!從沒有想過 Android 的頁面跳轉竟會變得如何簡單,但是 Navigation 的方案並不是原創,iOS 的 Storyboard 很早就支援拖拽生成路由。當年 Android 推出 ConstraintLayout 之時,我們都認為是參考了 Storyboard 的頁面拖拽,現在再配上 Navigation,從頁面到跳轉,一個完整的拖拽鏈路就形成了。平臺雖然有差異化,但是使用場景一致的前提下,解決方案也就殊途同歸了。


瞭解完了與生命週期有關的元件,接下來我們來看細節。


Paging

UI 沒有辦法一次性展示所有的資料,端上的系統資源(電量、記憶體)也有限制,不可能把所有資料都載入到記憶體中;而且大批量請求資料不但浪費頻寬,在某些網路情況(弱網、慢網)下還會導致請求失敗,所以分頁是很多情景下的剛需。Github 上有各式各樣的解決方案,這一次,Google 直接推出了官方的分頁元件——Paging。


Paging 將分頁邏輯拆解為三部分:

  • 資料來源 DataSource
  • 資料塊 PagedList
  • 資料展示 PagedListAdapter


DataSource 的資料來源於後端服務或者本地資料庫,並且用三個子類來表示三種分頁模式:

  • PageKeyedDataSource - 單頁資料以 page key 為標識,例如當前頁的 Response 中包含了下一頁的 url,這個 url 就是 page key。
  • ItemKeyedDataSource - 單頁資料以 item key 為標識,比如下一頁的請求要帶當前頁最後一個 item 的 id,這個 itemId 就是 item key。
  • PositionalDataSource - 單頁資料以位置為標識,這種模式比較常見,Room 只支援這一種,因為資料庫查詢以 OFFSET 和 LIMIT 做分頁。


PageKeyedDataSource 和 ItemKeyedDataSource 適用於記憶體型資料,比如直接從後端獲取後需要展示的資料。PositionalDataSource 適用於本地 Room 資料或者使用 Room 做快取的 Cache 資料。


資料流向的關係圖如下所示:


Paging.png


LivePagedListBuilder 利用 DataSource.FactoryPageList.Config 建立 LiveData,UI Controller 拿到資料之後交給 PagedListAdapter 展示到 RecyclerView。


上圖表達了資料的流向,如果從 UI 層往回看,頁面展示的資料儲存在 PagedList 中,PagedList 只是 DataSource 的資料塊(chunk),當 PagedList 需要更多資料時,DataSource 就會給更多,當 DataSource 一無所有時便會觸發 BoundaryCallback 獲取更多資料,直到資料全部展示完畢。


LivePagedListBuilder 會將 PagedList 包裝成 LiveData<PagedList> 給到下游,它在整個資料互動鏈路中的位置如下圖所示:

Paging sequence.png

Repository 拿到 Dao 的 DataSource.Factory 之後,呼叫它的 toLiveData 方法並傳入 PagedList.Config,然後生成一個分頁的 LiveData<PagedList> 交給 ViewModel 層。


Paging 加上生命週期相關的架構元件解決了資料儲存、資料流轉和資料展示的問題。除此之外,AAC 還包括一個強大的非同步任務執行器 WorkManager,它解決了任務執行的可靠性,無論 App 退出還是裝置重啟,交給 WorkerManager 的任務都會被執行。


WorkManager

WorkManager 雖然解決了任務執行可靠性的問題,但是它無法精確控制任務的執行時間,因為 WorkManager 要根據 OS 資源來選擇執行任務。Android 自身提供了很多方案來解決後臺任務執行的問題,可以根據下圖的決策路徑選擇不同的元件:

background.png

WorkManager 整體上可分為四部分:任務型別、任務構建、任務監控和任務控制。


一、任務型別,WorkManager 提供了一次性任務週期性任務兩種任務型別:

  • OneTimeWorkRequest —— 一次性任務
  • PeriodicTimeWorkRequest —— 週期性任務


二、任務構建,一是執行條件,二是執行順序。

  • Constraints —— 通過 Constraints.Builder 構建任務執行的條件(網路型別、電量、裝置空間等)
  • WorkContinuation —— 可以指定任務的執行順序,例如可以按照 PERT 圖的順序執行任務:


WorkContinuation.png


三、任務監控,通過回撥來獲知任務的當前狀態:

State.png


四、任務控制,包括加入佇列,取消任務,其中 UniqueWork 提供了多種加入佇列的策略(REPLACE、KEEP、APPEND):

  • cancelWorkById(UUID) —— 通過 ID 取消單個任務
  • cancelAllWorkByTag(String) —— 通過 Tag 取消所有任務
  • cancelUniqueWork(String) —— 通過名字取消唯一任務


除此之外,WorkerManager 還提供了四種不同執行緒模型的 Worker:

  • Worker —— 基於預設後臺執行緒
  • CoroutineWorker —— 基於 Kotlin 的協程
  • RxWorker —— 基於 RxJava2
  • ListenableWorker —— 基於回撥的非同步


總結

Google 官方架構元件 AAC 為我們提供了太多通用問題的解決方案,使用場景包括資料持久化、非同步任務排程、生命週期管理,UI 分頁、UI 導航,當然還有強大的 MVVM 框架 Data Binding,這些架構元件不但使程式碼變得清晰易讀,而且獨立於 Android SDK 向下相容,AAC 使我們更加聚焦產品,專注於解決問題,而不是花太多的時間重複造輪子


參考資料


歡迎關注我的個人公眾號【老樑寫程式碼】,第一時間獲取技術乾貨。



相關文章