作者:王鵬、孫永盛
來源:字節(jié)跳動技術(shù)團隊
What is MAD?

MAD 的全稱是 Modern Android Development,它是一系列技術(shù)棧和工具鏈的集合,涵蓋了從編程語言到開發(fā)框架等各個環(huán)節(jié)。

Android 自 08 年誕生之后的多年間 SDK 變化一直不大,開發(fā)方式較為固定。13 年起技術(shù)更新逐漸加速,特別是 17 年之后, 隨著 Kotlin 及 Jetpack 等新技術(shù)的出現(xiàn) Android 開發(fā)方式發(fā)生了很大變化,去年推出的 Jetpack Compose 更是將這種變化推向了新階段。Goolge 將這些新技術(shù)下的開發(fā)方式命名為 MAD ,以此區(qū)別于舊有的低效的開發(fā)方式。
MAD 可以指導(dǎo)開發(fā)者更高效地開發(fā)出優(yōu)秀的移動應(yīng)用,它的優(yōu)勢主要體現(xiàn)在以下幾點:
- 值得信賴:匯聚 Google 在 Android 行業(yè)十余年的前沿開發(fā)經(jīng)驗
- 入門友好:提供大量 Demo 和參考文檔,適用于不同階段不同規(guī)模的項目
- 高效啟動:通過 Jeptack 以及 Jetpack Compose 等框架,可以迅速搭建你的項目
- 自由選擇:框架豐富多樣,可與傳統(tǒng)語言、原生開發(fā)、開源框架自由搭配
- 體驗一致:不同設(shè)備不同版本系統(tǒng)下也具備一致的開發(fā)體驗
MAD 助力應(yīng)用出海
近期我們完成了一款 AI 特效類應(yīng)用在 GooglePlay 的上架,此應(yīng)用可將用戶自己的頭像圖片經(jīng)算法加工成各種藝術(shù)效果。應(yīng)用一經(jīng)上架便廣受好評,這一切正是得益于我們在項目中對 MAD 技術(shù)的綜合運用,我們在最短時間內(nèi)完成了全部開發(fā),并打造了出色的用戶體驗。

在 MAD 的指導(dǎo)下項目的代碼架構(gòu)也更加合理、更具可維護性。下圖是項目中 MAD 的整體應(yīng)用情況:

接下來,本文將分享一些我們在對 MAD 實踐過程中的心得和案例。
1. Kotlin

Kotlin 是 Andorid 認(rèn)可的首選開發(fā)語言,我們的項目中,所有代碼都使用 Kotlin 開發(fā)。Kotlin 的語法十分簡潔,相對于 Java 同等功能的代碼規(guī)??梢詼p少 25%。此外 Kotlin 還具有很多 Java 所不具備的優(yōu)秀特性:
1.1 Safety
Kotlin 在安全性方面有很多優(yōu)秀的設(shè)計,比如空安全以及數(shù)據(jù)的不可變性。
Null Safety
Kotlin 的空安全特性讓很多運行時 NPE 提前到編譯期暴露和發(fā)現(xiàn),有效降低線上崩潰的發(fā)生。我們在代碼中重視對 Nullable 類型的判斷和處理,我們在數(shù)據(jù)結(jié)構(gòu)定義時都力求避免出現(xiàn)可空類型,最大限度降低判空成本;
interface ISelectedStateController<DATA> {
fun getStateOrNull(data: DATA): SelectedState?
fun selectAndGetState(data: DATA): SelectedState
fun cancelAndGetState(data: DATA): SelectedState
fun clearSelectState()
}
// 使用 Elvis 提前處理 Nullable
fun <DATA> ISelectedStateController<DATA>.getSelectState(data: DATA): SelectedState {
return getStateOrNull(data) ?: SelectedState.NON_SELECTED
}
Java 時代我們只能通過 getStateOrNull 這類的命名規(guī)范來提醒返回值的可空,Kotlin 通過 ?讓我們可以更好地感知 Nullable 的風(fēng)險;我們還可以使用 Elvis 操作符 ?: 將 Nullable 轉(zhuǎn)成 NonNull 便于后續(xù)使用;Kotlin 的 !! 讓我們更容易發(fā)現(xiàn) NPE 的潛在風(fēng)險并可以訴諸靜態(tài)檢查給予警告。
Kotlin 的默認(rèn)參數(shù)值特性也可以用來防止 NPE 的出現(xiàn),像下面這樣的結(jié)構(gòu)體定義,在反序列化等場景中不必?fù)?dān)心 Null 的出現(xiàn)。
data class BannerResponse(
@SerializedName("data") val data: BannerData = BannerData(),
@SerializedName("message") val message: String = "",
@SerializedName("status_code") val statusCode: Int = 0
)
我們在全面擁抱 Kotlin 之后,NPE 方面的崩潰率只有 0.3 ‰,而通常 Java 項目的 NPE 會超過 1 ‰
Immutable
Kotlin 的安全性還體現(xiàn)在數(shù)據(jù)不會被隨意修改。我們在代碼中大量使用 data class 并且要求屬性使用 val 而非 var 定義,這有利于單向數(shù)據(jù)流范式在項目中的推廣,在架構(gòu)層面實現(xiàn)數(shù)據(jù)的讀寫分離。
data class HomeUiState(
val bannerList: Result<BannerItemModel> = Result.Success(emptyList()),
val contentList: Result<ContentViewModel> = Result.Success(emptyList()),
)
sealed class Result<T> {
data class Success<T>(val list: List<T> = emptyList()) : Result<T>()
data class Error<T>(val message: String) : Result<T>()
}
如上,我們使用 data class 定義 UiState 用在 ViewModel 中。val 聲明屬性保證了 State 的不可變性。使用密封類定義 Result 有利于對各種請求結(jié)果進行枚舉,簡化邏輯。
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
_uiState.value =
_uiState.value.copy(bannerList = Result.Success(it))
需要更新 State 時,借助 data class 的 copy 方法可以快捷地拷貝構(gòu)造一個新實例。
Immutable 還體現(xiàn)在集合類的類型上。我們在項目中提倡非必要不使用 MutableList 這樣的 Mutable 類型,可以減少 ConcurrentModificationException 等多線程問題的發(fā)生,同時更重要的是避免了因為 Item 篡改帶來的數(shù)據(jù)一致性問題:
viewModel.uiState.collect {
when (it) {
Result.Success -> bannerAdapter.updateList(it.list)
else {...}
}
}
fun updateList(newList: List<BannerItemModel>) {
val diffResult = DiffUtil.calculateDiff(BannerDiffCallback(mList, newList), true)
diffResult.dispatchUpdatesTo(this)
}
比如上面例子中 UI 側(cè)接收到 UiState 更新通知后,提交 DiffUtil 刷新列表。DiffUtil 正常運作的基礎(chǔ)正是因為 mList 和 newList 能時刻保持 Immutable 類型。
1.2 Functional
函數(shù)在 Kotlin 中是一等公民,可以作為參數(shù)或返回值的類型組成高階函數(shù),高階函數(shù)可以在集合操作符等場景下提供更加易用的 API。
Collection operations
val bannerImageList: List<BannerImageItem> =
bannerModelList.sortedBy {
it.bType
}.filter {
!it.isFrozen()
}.map {
it.image
}
上面的代碼中我們對 BannerModelList 依次完成排序、過濾,并轉(zhuǎn)換成 BannerImageItem 類型的列表,集合操作符的使用讓代碼一氣呵成。
Scope functions
作用域函數(shù)是一系列 inline 的高階函數(shù)。它們可以作為代碼的粘合劑,減少臨時變量等多余代碼的出現(xiàn)。
GalleryFragment().apply {
setArguments(arguments ?: Bundle().apply {
putInt("layoutId", layoutId())
})
}.let { fragment ->
supportFragmentManager.beginTransaction()
.apply {
if (needAdd) add(R.id.fragment_container, fragment, tag)
else replace(R.id.fragment_container, fragment, tag)
}.also{
it.setCustomAnimations(R.anim.slide_in, R.anim.slide_out)
}.commit()
}
當(dāng)我們創(chuàng)建并啟動一個 Fragment 時,可以基于作用域函數(shù)完成各種初始化工作,就像上面例子那樣。這個例子同時也提醒我們過度使用這些作用域函數(shù)(或集合操作符),也會影響代碼的可讀性和可調(diào)試性,只有“恰到好處”的使用函數(shù)式編程才能真正發(fā)揮 Kotlin 的優(yōu)勢。
1.3 Corroutine
Kotlin 協(xié)程讓開發(fā)者擺脫了回調(diào)地獄的出現(xiàn),同時結(jié)構(gòu)化并發(fā)的特性也有助于對子任務(wù)更好地管理,Android 的各種原生庫和三方庫在處理異步任務(wù)時都開始轉(zhuǎn)向 Kotlin 協(xié)程。
Suspend function
在項目中,我們倡導(dǎo)使用掛起函數(shù)封裝異步邏輯。在數(shù)據(jù)層 Room 或者 Retorfit 使用掛起函數(shù)風(fēng)格的 API 自不必說,一些表現(xiàn)層邏輯也可以基于掛起函數(shù)來實現(xiàn):
suspend fun doShare(
activity: Activity,
contentBuilder: ShareContent.Builder.() -> Unit
): ShareResult = suspendCancellableCoroutine { cont ->
val shareModel = ShareContent.Builder()
.setEventCallBack(object : ShareEventCallback.EmptyShareEventCallBack() {
override fun onShareResultEvent(result: ShareResult) {
super.onShareResultEvent(result)
if (result.errorCode == 0) {
cont.resume(result)
} else {
cont.cancel()
}
}
}).apply(contentBuilder)
.build()
ShareSdk.showPanel(createPanelContent(activity, shareModel))
}
上例的 doShare 用掛起函數(shù)處理照片的分享邏輯:彈出分享面板供用戶選擇分享渠道,并將分享結(jié)果返回給調(diào)用方。調(diào)用方啟動分享并同步獲取分享成功或失敗的結(jié)果,代碼風(fēng)格更符合直覺。
Flow
項目中使用 Flow 替代 RxJava 處理流式數(shù)據(jù),減少包體積的同時,CoroutineScope 可以有效避免數(shù)據(jù)泄露:
fun CoroutineScope.getBannerList(): Flow<List<BannerItemModel>> =
DatabaseManager.db.bannerDao::getAll.asFlow()
.onCompletion {
this@Repository::getRemoteBannerList.asFlow().onEach {
launch {
DatabaseManager.db.bannerDao.deleteAll()
DatabaseManager.db.bannerDao.insertAll(*(it.toTypedArray()))
}
}
}.distinctUntilChanged()
上面的例子用于從多個數(shù)據(jù)源獲取 BannerList 。我們增加了磁盤緩存的策略,先請求本地數(shù)據(jù)庫數(shù)據(jù),再請求遠(yuǎn)程數(shù)據(jù)。Flow 的使用可以很好地滿足這類涉及多數(shù)據(jù)源請求的場景。而另一面在調(diào)用側(cè),只要提供合適的 CoroutineScope 就不必?fù)?dān)心泄露的發(fā)生。
1.4 KTX
一些原本基于 Java 實現(xiàn)的 Android 庫通過 KTX 提供了針對 Kotlin 的擴展 API,讓它們在 Kotlin 工程中更容易地被使用。
我們的項目使用 Jetpack Architecture Components 搭建 App 基礎(chǔ)架構(gòu),KTX 幫助我們大大降低了 Kotlin 項目中的 API 使用成本,舉幾個最常見的 KTX 的例子:
fragment-ktx
fragment-ktx 提供了一些針對 Fragment 的 Kotlin 擴展方法,比如 ViewModel 的創(chuàng)建:
class HomeFragment : Fragment() {
private val homeViewModel : HomeViewModel by viewModels()
...
}
相對于 Java 代碼在 Fragment 中創(chuàng)建 ViewMoel 變得極其簡單,其背后的是現(xiàn)實活用了各種 Kotlin 特性,十分巧妙。
inline fun <reified VM : ViewModel> Fragment.viewModels(
noinline ownerProducer: () -> ViewModelStoreOwner = { this },
noinline factoryProducer: (() -> Factory)? = null
) = createViewModelLazy(VM::class, { ownerProducer().viewModelStore }, factoryProducer)
viewModels 是 Fragment 的 inline 擴展方法,通過 reified 關(guān)鍵字在運行時獲取泛型類型用來創(chuàng)建具體 ViewModel 實例:
fun <VM : ViewModel> Fragment.createViewModelLazy(
viewModelClass: KClass<VM>,
storeProducer: () -> ViewModelStore,
factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
val factoryPromise = factoryProducer ?: {
defaultViewModelProviderFactory
}
return ViewModelLazy(viewModelClass, storeProducer, factoryPromise)
}
createViewModelLazy 返回了一個 Lazy<VM> 實例,這似的我們可以通過 by 關(guān)鍵字創(chuàng)建 ViewModel,這里借助 Kotlin 的代理特性實現(xiàn)了實例的延遲創(chuàng)建。
viewmodle-ktx
viewModel-ktx 提供了針對 ViewModel 的擴展方法, 例如 viewModelScope,可以隨著 ViewModel 的銷毀及時終止過期的異步任務(wù),讓 ViewModel 更安全地作為數(shù)據(jù)層與表現(xiàn)層之間的橋梁使用。
viewModelScope.launch {
//監(jiān)聽數(shù)據(jù)層的數(shù)據(jù)
repo.getMessage().collect {
//向表現(xiàn)層發(fā)送消息
_messageFlow.emit(message)
}
}
實現(xiàn)原理也非常簡單:
val ViewModel.viewModelScope: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
}
viewModelScope 本質(zhì)上是 ViewModle 的擴展屬性,通過 custom get 創(chuàng)建 CloseableCoroutineScope 的同時,記錄到 JOB_KEY 的位置中。
internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
override val coroutineContext: CoroutineContext = context
override fun close() {
coroutineContext.cancel()
}
}
CloseableCoroutineScope 其實是一個 Closeable,在 ViewModel 的 onClear 時查找 JOB_KEY 并被調(diào)用 close 以取消 SupervisorJob ,終止所有子協(xié)程。KTX 活用了 Kotlin 的各種特性和語法糖 ,后面 Jetpack 章節(jié)會看到更多 KTX 的使用。
2. Android Jetpack

Android 通過 Jetpack 為開發(fā)者提供 AOSP 之上的基礎(chǔ)能力支持,其范圍覆蓋了從 UI 到 Data 各個層級,降低了開發(fā)者們自造輪子的需求。近期 Jetpack 組件的架構(gòu)規(guī)范又進行了全面升級,幫助我們在開發(fā)過程中能更好地貫徹關(guān)注點分離這一設(shè)計目標(biāo)。
2.1 Architecture
Android 倡導(dǎo)表現(xiàn)層和數(shù)據(jù)層分離的架構(gòu)設(shè)計,并使用單向數(shù)據(jù)流(Unidirectional Data Flow)完成數(shù)據(jù)通信。Jetpack 通過一系列 Lifecycle-aware 的組件支持了 UDF 在 Android 中的落地。

UDF 的主要特點和優(yōu)勢如下:
- 唯一真實源(SSOT):UI State 在 ViewModel 集中管理,降低了多數(shù)據(jù)源之間的同步成本
- 數(shù)據(jù)自上而下流動:UI 的更新來 VM 的狀態(tài)變化,UI 自身不持有狀態(tài)、不耦合業(yè)務(wù)邏輯
- 事件自下而上傳遞:UI 發(fā)送 event 給 VM 對狀態(tài)集中修改,狀態(tài)變化可回溯、利于單測
項目中凡是涉及 UI 的業(yè)務(wù)場景都是基于 UDF 打造的。以 HomePage 為例,其中包括 BannerList 和 ContentList 兩組數(shù)據(jù)展示,所有的數(shù)據(jù)集中管理在 UiState 中。
class HomeViewModel() : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun fetchHomeData() {
fetchJob?.cancel()
fetchJob = viewModelScope.launch {
with(repo) {
//request BannerList
try {
getBannerList().collect {
_uiState.value =
_uiState.value.copy(bannerList = Result.Success(it))
}
} catch (ioe: IOException) {
// Handle the error and notify the UI when appropriate.
_uiState.value =
_uiState.value.copy(
bannerList = Result.Error(getMessagesFromThrowable(ioe))
)
}
//request ContentList
try {
getContentList().collect {
_uiState.value =
_uiState.value.copy(contentList = Result.Success(it))
}
} catch (ioe: IOException) {
_uiState.value =
_uiState.value.copy(
contentList = Result.Error(getMessagesFromThrowable(ioe))
)
}
}
}
}
}
如上代碼所示,HomeViewModel 從 Repo 獲取數(shù)據(jù)并更新 UiState,View 訂閱此狀態(tài)并刷新 UI。viewModelScope.launch 提供的 CoroutineScope 可以隨著 ViewModel 的 onClear 結(jié)束運行中的協(xié)程,避免泄露。
數(shù)據(jù)層我們使用 Repository Pattern 封裝本地數(shù)據(jù)源和遠(yuǎn)程數(shù)據(jù)源的具體實現(xiàn):
class Repository {
fun CoroutineScope.getBannerList(): Flow<List<BannerItemModel>> {
return DatabaseManager.db.bannerDao::getAll.asFlow()
.onCompletion {
this@Repository::getRemoteBannerList.asFlow().onEach {
launch {
DatabaseManager.db.bannerDao.deleteAll()
DatabaseManager.db.bannerDao.insertAll(*(it.toTypedArray()))
}
}
}.distinctUntilChanged()
}
private suspend fun getRemoteBannerList(): List<BannerItemModel> {
TODO("Not yet implemented")
}
}
以 getBannerList 為例,先從數(shù)據(jù)庫請求本地數(shù)據(jù)加速顯示,然后再請求遠(yuǎn)程數(shù)據(jù)源更新數(shù)據(jù),同時進行持久化,便于下次請求。
UI 層的邏輯很簡單,訂閱 ViewModel 的數(shù)據(jù)并刷新 UI 即可。
@AndroidEntryPoint
class HomeFragment : Fragment() {
@Inject
lateinit var viewModel : HomeViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect {
// Update UI elements
}
}
}
}
}
我們使用 Flow 代替 LiveData 對 UiState 進行封裝,lifecycleScope 使得 Flow 變身 Lifecycle-aware 組件;repeatOnLifecycle 讓 Flow 像 LiveData 一樣在 Fragment 前后臺切換時自動停止數(shù)據(jù)流的發(fā)射,節(jié)省資源開銷。
2.2 Navigation
作為“單 Activity 架構(gòu)”的實踐者,我們選擇了使用 Jetpack Navigation 作為 App 的導(dǎo)航組件。Navigation 組件實現(xiàn)了導(dǎo)航設(shè)計原則,為跨應(yīng)用切換或應(yīng)用內(nèi)頁面間的切換提供了一致的用戶體驗,并且提供了各種優(yōu)勢,包括:
- 處理 Fragment 事務(wù);
- 默認(rèn)情況下,正確處理往返操作;
- 為動畫和轉(zhuǎn)場提供標(biāo)準(zhǔn)化資源;
- 實現(xiàn)和處理深層鏈接;
- 包括導(dǎo)航界面模式(例如抽屜式導(dǎo)航欄和底部導(dǎo)航),開發(fā)者只需完成極少的額外工作;
- 提供 Gradle 插件用以保證在不同頁面?zhèn)鬟f參數(shù)時類型安全;
- 提供了導(dǎo)航圖范圍的 ViewModel,以在同導(dǎo)航圖內(nèi)的頁面進行數(shù)據(jù)共享;

Navigation 提供了 XML 以及 Kotlin DSL 兩種配置方式。我們在項目中發(fā)揮 Kotin 的優(yōu)勢,基于類型安全的 DSL 創(chuàng)建導(dǎo)航圖,同時通過函數(shù)提取為頁面統(tǒng)一指定轉(zhuǎn)場動畫:
fun NavHostFragment.initGraph() = run {
createGraph(nav_graph.id, nav_graph.dest.home) {
fragment<HomeFragment>(nav_graph.dest.effect_detail) {
action(nav_graph.action.home_to_effect_detail) {
destinationId = nav_graph.dest.effect_detail
navOptions {
applySlideInOut()
}
}
}
}
}
//統(tǒng)一指定轉(zhuǎn)場動畫
internal fun NavOptionsBuilder.applySlideInOut() {
anim {
enter = R.anim.slide_in
exit = R.anim.slide_out
popEnter = R.anim.slide_in_pop
popExit = R.anim.slide_out_pop
}
}
在 Activity 中,調(diào)用 initGraph() 為 Root Fragment 初始化導(dǎo)航圖:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val navHostFragment: NavHostFragment by lazy {
supportFragmentManager.findFragmentById(R.id.nav_host) as NavHostFragment
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
navHostFragment.navController.apply {
graph = navHostFragment.initGraph()
}
}
}
而在 Fragment 中,使用 navigation-fragment-ktx 提供的 findNavController() 可以隨時基于當(dāng)前 Destination 進行正確地頁面跳轉(zhuǎn):
@AndroidEntryPoint
class EffectDetailFragment : Fragment() {
/* ... */
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
nextButton.setOnClickListener {
findNavController().navigate(nav_graph.action.effect_detail_to_loading))
}
// Back to previous page
backButton.setOnClickListener {
findNavController().popBackStack()
}
// Back to home page
homeButton.setOnClickListener {
findNavController().popBackStack(nav_graph.dest.home, false)
}
}
}
除此以外,我們可以聲明全局頁面導(dǎo)航,這種方式在引導(dǎo)用戶登錄注冊或前往反饋頁等場景有很大用處:
fun NavHostFragment.initGraph() = run {
createGraph(nav_graph.id, nav_graph.dest.home) {
/* ... some Fragment destination declaration ... */
// --------------- Global ---------------
action(nav_graph.action.global_to_register) {
destinationId = nav_graph.dest.register
navOptions {
applyBottomSheetInOut()
}
}
}
}
2.3 Hilt
依賴注入 (Dependency Injection) 是多 Module 工程中的常用的技術(shù),依賴注入作為控制反轉(zhuǎn)設(shè)計原則的一種實現(xiàn)方式,有利于實例的生產(chǎn)側(cè)與消費側(cè)的解耦,踐行了關(guān)注點分離的設(shè)計原則,也更有助于單元測試的編寫。

Hilt 在 Dagger 的基礎(chǔ)上構(gòu)建而成,繼承了 Dagger 編譯時檢查、運行時高性能、可伸縮等優(yōu)點的同時提供了更友好的 API ,使得 Dagger 使用成本大幅降低。Android Studio 也內(nèi)置了對 Dagger/Hilt 的支持,后文會介紹。
項目中大量使用了 Hilt 完成依賴注入,進一步提升了代碼的編寫效率。我們使用 @Singleton 提供 Repository 的單例實現(xiàn),當(dāng) Repository 需要 Context 來創(chuàng)建 SharedPreferences 或者 DataStore 時,使用 @ApplicationContext 注解傳入應(yīng)用級別的 Context,在需要的地方只需要 **@Inject **即可注入對象:
@AndroidEntryPoint
class RecommendFragment : Fragment() {
@Inject
lateinit var recommendRepository: RecommendRepository
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recommendRepository.doSomeThing()
}
}
對于一些無法在構(gòu)造函數(shù)中增加注解的三方庫的類,我們可以使用 @Provides 來告訴 Hilt 如何創(chuàng)建相關(guān)實例。例如提供創(chuàng)建 Retorfit API 的實現(xiàn),省去每次手動創(chuàng)建的工作。
@Module
@InstallIn(ActivityComponent::class)
object ApiModule {
@Provides
fun provideRecommendServiceApi(): RecommendServiceApi {
return Retrofit.Builder()
.baseUrl("https://example.com")
.build()
.create(RecommendServiceApi::class.java)
}
}
得益于 Hilt 對 Jetpack 其他組件的支持,在 ViewModel 或者 WorkManager 中也同樣可以使用 Hilt 進行依賴注入。
@HiltViewModel
class RecommendViewModel @Inject constructor(
private val recommendRepository: RecommendRepository
) {
val recommendList = recommendRepository.fetchRecommendList()
.flatMapLatest {
flow { emit(it) }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
}
2.4 WorkManager
WorkManager 是針對持久性工作而推出的 Jetpack 庫,所謂持久性工作指可以跨越應(yīng)用或者系統(tǒng)重啟持續(xù)執(zhí)行的任務(wù),比如應(yīng)用數(shù)據(jù)與服務(wù)器之間進行同步,或者是上傳日志等。WorkManager 對內(nèi)會根據(jù)策略自動選擇 FirebaseJobDispatcher、GcmNetworkManager 或 JobScheduler 等執(zhí)行調(diào)度任務(wù),對外則提供了簡單一致的 API 方便使用。
WorkManager 默認(rèn)使用 Jetpack StartUp 庫進行初始化,開發(fā)者只需關(guān)注定義與實現(xiàn) Worker 即可,無需其他額外工作。WorkManager 向后兼容到 Android 6.0 、覆蓋了市面上絕大多數(shù)的機型,可以有效取代 Service 完成那些需要長期執(zhí)行的后臺任務(wù)。
產(chǎn)品為了減少用戶生成頭像時上傳圖片所需時間與流量消耗,會在上傳之前對圖片進行壓縮,但是壓縮過程的臨時文件會增加 App 所占存儲空間,所以我們使用 WorkManager 對清理壓縮圖片緩存的工作進行調(diào)度,在 App 啟動后將任務(wù)提交給 WorkManager:
val deleteImageCacheRequest = OneTimeWorkRequestBuilder<DeleteImageCacheWorker>().build()
WorkManager.getInstance(this).enqueue(deleteImageCacheRequest)
class DeleteImageCacheWorker(
context: Context,
workParams: WorkerParameters
) : Worker(context, workParams) {
override fun doWork(): Result {
return try {
/* ... do the work ... */
Result.success()
} catch (e: Exception) {
/* return failure() or retry() */
Result.failure()
}
}
}
還有一種場景是用戶下載圖片。下載需要網(wǎng)絡(luò),并且此工作的優(yōu)先級比較高,因此可以使用 WorkManager 提供的工作約束以及加急工作 (WorkManager 2.7 及以上) 等能力,除此以外還可以對工作的結(jié)果信息進行監(jiān)聽,以對用戶進行提示:
val downloadImageRequest = OneTimeWorkRequestBuilder<DownLoadImageWorker>()
.setInputData(workDataOf("url" to "https://the-url-of-image.com"))
// set network constraint
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
)
// make worker expedited
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(context).enqueue(downloadImageRequest)
val downloadImageFlow = WorkManager.getInstance(context)
.getWorkInfoByIdLiveData(downloadImageRequest.id)
.asFlow()
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 1
)
// in Fragment
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
downloadImageFlow.collectLatest {
when (it?.state) {
WorkInfo.State.ENQUEUED -> {}
WorkInfo.State.RUNNING -> {}
WorkInfo.State.SUCCEEDED -> {}
WorkInfo.State.BLOCKED -> {}
WorkInfo.State.FAILED -> {}
WorkInfo.State.CANCELLED -> {}
}
}
}
}
2.5 StartUp
應(yīng)用啟動時需要做大量初始化工作,例如 SDK 的初始化、基礎(chǔ)模塊的配置等。StartUp 出現(xiàn)之前我們使用 ContentProvider 完成“無侵”的初始化,避免 init(Context) 這類代碼在 Application 中的出現(xiàn)。但是 ContentProvider 的創(chuàng)建成本較高,多個 ContentProvider 同時創(chuàng)建會拖慢應(yīng)用啟動速度且初始化時序不可控。
StartUp 只使用一個 ContentProvider 來完成多個組件的初始化,很好地解決了上述 ContentProvider 的各種問題。此外,StartUp 還可以避免 app 模塊對其他模塊的非必要依賴。例如我們在項目中需要為 local test 渠道單獨依賴一個 Module,此 Module 依賴 Context 完成初始化,但我們不希望它被打入 release 包。此時要像下面這樣添加 Gradle 依賴即可,app 不需要在代碼層面依賴 local_test 模塊。
if (BuildContext.isLocalTest()) {
implementation project(':local_test')
}
StartUp 庫的使用非常簡單,只需定義一個 Initializer 即可, 定義的同時還可以配置初始化的依賴項,確保核心組件可以最先完成初始化:
class ServerInitializer : Initializer<ServerManager> {
override fun create(context: Context): ServerManager {
TODO("init ServerManager and return")
}
override fun dependencies(): List<Class<out Initializer<*>>> {
return emptyList()
}
}
class AccountInitializer : Initializer<Unit> {
override fun create(context: Context) {
TODO("init Account")
}
override fun dependencies(): List<Class<out Initializer<*>>> {
return listOf(ServerInitializer::class.java)
}
}
在上面的例子中,Account 模塊的初始化將會等待 Server 模塊初始化完成后才會繼續(xù)。
2.6 Room
local-first 架構(gòu)的 App 可以提供良好的用戶體驗,當(dāng)設(shè)備無法訪問網(wǎng)絡(luò)時,用戶仍可在離線狀態(tài)下瀏覽相應(yīng)內(nèi)容。Android 提供了 SQLite 作為訪問數(shù)據(jù)庫的 API,但是 SQLite API 比較底層,需要人工確保 SQL 語句的正確性,除此以外,還需要編寫大量的模板代碼來完成 PO 與 DO 之間的轉(zhuǎn)換。Jetpack Room 在 SQLite 的基礎(chǔ)上提供了一個抽象層,幫助開發(fā)者更流暢的訪問數(shù)據(jù)庫。
Room 主要包含 3 個組件:Database 是數(shù)據(jù)庫持有者,是與底層數(shù)據(jù)庫連接的主要接入點;Entity 代表數(shù)據(jù)庫中的表;DAO 包含用于訪問數(shù)據(jù)庫的方法。3 個組件通過注解進行聲明:
@Entity(tableName = "tb_banner")
data class Banner(
@PrimaryKey
val id: Long,
@ColumnInfo(name = "url")
val url: String
)
@Dao
interface BannerDao {
@Query("SELECT * FROM tb_banner")
fun getAll(): List<Banner>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertBanner(banner: Banner)
}
@Database(entities = arrayOf(Banner::class), version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun bannerDao(): BannerDao
}
需要注意的是創(chuàng)建數(shù)據(jù)庫的成本比較高,所以單進程 App 內(nèi)要保證數(shù)據(jù)庫為單例:
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext applicationContext: Context
): AppDatabase {
return Room.databaseBuilder(
applicationContext,
AppDatabase::class.java, "database-name"
).build()
}
@Provides
@Singleton
fun provideBannerDao(
appDatabase: AppDatabase
): BannerDao {
return appDatabase.bannerDao()
}
}
當(dāng)數(shù)據(jù)庫中的數(shù)據(jù)發(fā)生更新時,我們希望 UI 也能隨之自動刷新。得益于 Room 對 Coroutine 以及 RxJava 良好的支持,只需要引入 room-ktx 庫或者 room-rxjava2/3 庫,DAO 中的方法也可以直接返回 Flow 或者 Observable,或者直接使用掛起函數(shù):
@Dao
interface BannerDao {
@Query("SELECT * FROM tb_banner")
fun getAll(): Flow<List<Banner>>
@Query("SELECT * FROM tb_banner")
suspend fun getAllSuspend(): List<Banner>>
}
這時候我們只需要在 UI 層對 Flow 進行訂閱,便可以做到當(dāng)數(shù)據(jù)庫內(nèi)容更新時 UI 也隨之更新:
@HiltViewModel
class BannerViewModel @Inject constructor (
// we should use repository rather than access BannerDao directly
private val bannerDao: BannerDao
) : ViewModel() {
val bannerList: Flow<BannerVO> = bannerDao.getAll().map {
it.toVO()
}
}
// in Fragment
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
bannerViewModel.bannerList.collectLatest {
bannerAdapter.submitList(it)
}
}
}
3. Android Studio

Android Studio 誕生至今一直保持著活躍的版本更新,當(dāng)前最新版本已經(jīng)更新至 Bumblebee | 2021.1.1.21 ,自 4.3 Canary 1 以來 Android Studio 在命名風(fēng)格上有所調(diào)整,更好的對齊了 IntelliJ 平臺版本。除了定期發(fā)布的穩(wěn)定版,開發(fā)者還可以通過 RC 和 Preview 版本提前體驗更多新鮮功能。
隨著版本的不斷更新,編寫和調(diào)試代碼的體驗得到持續(xù)的優(yōu)化,且集成了越來越多的新功能。Layout Instpector ,Device Exploer 等既有功能自不必說,以下這些新特性也為我們的開發(fā)、調(diào)試提供了巨大的便利。
3.1 Database Inspector

我們使用 Room 進行數(shù)據(jù)持久化,Database Inspector 可以實時查看 Jetpack Room 框架生成的數(shù)據(jù)庫文件,同時也支持實時編輯和部署到設(shè)備當(dāng)中。相較之前需要的 SQLite 命令或者額外導(dǎo)出并借助 DB 工具的方式更為高效和直觀。
3.2 Realtime Profilers

Android Studio 的 Realtime Profilers 工具可以幫助我們在如下四個方面監(jiān)測和發(fā)現(xiàn)問題,有時在缺少工程代碼的情況下通過 Memory Profilers 還可以查看其內(nèi)部的實例和變量細(xì)節(jié)。
- CPU:性能剖析器檢查 CPU 活動,切換到 Frames 視圖還可以界面卡頓追蹤
- Memory:識別可能會導(dǎo)致應(yīng)用卡頓、凍結(jié)甚至崩潰的內(nèi)存泄漏和內(nèi)存抖動,可以捕獲堆轉(zhuǎn)儲、強制執(zhí)行垃圾回收以及跟蹤內(nèi)存分配以定位內(nèi)存方面的問題
- Battery:會監(jiān)控 CPU、網(wǎng)絡(luò)無線裝置和 GPS 傳感器的使用情況,并直觀地顯示其中每個組件消耗的電量,了解應(yīng)用在哪里耗用了不必要的電量
- Network:顯示實時網(wǎng)絡(luò)活動,包括發(fā)送和接收的數(shù)據(jù)以及當(dāng)前的連接數(shù)。這便于您檢查應(yīng)用傳輸數(shù)據(jù)的方式和時間,并適當(dāng)優(yōu)化代碼
3.3 APK Analyzer

Apk 的下載會耗費網(wǎng)絡(luò)流量,安裝了還會占用存儲空間。其體積的大小會對 App 安裝和留存產(chǎn)生影響,分析和優(yōu)化其體積顯得尤為必要。
借助 AS 的 APK Analyzer 可以幫助完成如下幾項工作:
- 快速分析 Apk 構(gòu)成,包括 DEX、Resources 和 Manifest 的 Size 和占比,助力我們優(yōu)化代碼或資源的方向
- Diff Apk 以了解版本的前后差異,精準(zhǔn)定位體積變大的源頭
- 分析其他 Apk,包括查看大致的資源和分析代碼邏輯,進而拆解、Bug 定位
3.4 DI Navigation
依賴注入有助于模塊間的解耦,踐行了關(guān)注點分離的設(shè)計原則。我們使用 Dagger / Hilt 通過編譯期代碼生成隱藏了相關(guān)具體實現(xiàn),這在降低構(gòu)建依賴關(guān)系圖的成本的同時,也增加了開發(fā)者調(diào)試代碼的成本:尋找被注入實例的來源變得困難起來。
如今 Android Studio 幫開發(fā)者解決了這個痛點。自 4.1 我們可以在基于 Dagger 的代碼(例如 Components,Subcomponents,Modules 等)中跳轉(zhuǎn),找尋依賴關(guān)系。

在 Dagger 或 Hilt 相關(guān)的代碼旁可以看到下面的 icon:

點擊左側(cè) icon 可以跳轉(zhuǎn)到實例對象的提供處,點擊右側(cè) icon 則可以跳轉(zhuǎn)到對象的使用處,當(dāng)有多處使用時則會給出候選列表供選擇。
Android 4.2 起還增加了對 @EnterPoint 的依賴查詢,對于 ContentProvider 這樣的不能自動注入的組件,也可以通過 Hilt 擴大依賴注入的使用范圍。
4. App Bundle

Android App Bundle 是 Google 推出的用于動態(tài)化分發(fā)的打包格式。當(dāng)應(yīng)用程序以 AAB 的格式上傳 Google Play(或其他支持 AAB 的應(yīng)用市場)后,可以根據(jù)需要實現(xiàn)功能或資源的動態(tài)下發(fā)。

Split APKs 機制是 AAB 實現(xiàn)動態(tài)下發(fā)的基礎(chǔ),AAB 上傳 GP 后被拆分成一個 base APK 和多個 Split APKs。首次下載只下發(fā) Base APK,然后根據(jù)使用場景動態(tài)下發(fā) Split APKs。Split 可以使 Configuration APKs ,也可以是一個 Dynamic Features APKs:
- Configuration APKs:根據(jù) language,density,abi 三個維度拆分資源,比如 res/drawable-xhdpi 會被拆分到 xhdpi 的 Apk 中,res/values-en 會被拆分到 en 的 apk 中,當(dāng) Configurations Changed 發(fā)生時請求必要資源
- Dynamic Features APKs:可以實現(xiàn) Feature 的按需動態(tài)加載,這類似于國內(nèi)流行的“插件化”技術(shù),通過將一些非常用的功能做成 Dynamic Feature 可以實現(xiàn)功能的按需加載。
Google 重視 AAB 格式的推廣,自 21 年 8 月起,規(guī)定新 App 必須使用 AAB 格式才能在 Google Play 上架。作一款要在海外上架的產(chǎn)品,我們自然也選擇了 AAB 的交付方式,除了在包體積方面的顯著受益,也較好地助力了產(chǎn)品推廣和裝機率的提升。

4.1 Language Split
我們的應(yīng)用在多個國家同時上架,需要支持英語、印尼語、葡語等多種語言,借助 AAB 可以避免下載其他國家的語言資源。
語言動態(tài)下發(fā)非常簡單,首先在 Gradle 開啟 language 的 enableSplit:
bundle {
language {
enableSplit = true
}
}
切換系統(tǒng)語言時,應(yīng)用會通過 GP 自動下載所需的語言。當(dāng)然也可以根據(jù)業(yè)務(wù)需求手動請求語言資源,比如在我們內(nèi)置的語言切換界面中選擇其他語言時:
private val _splitListener = SplitInstallStateUpdatedListener { state ->
val lang = if (state.languages().isNotEmpty())
state.languages().first() else ""
when (state.status()) {
SplitInstallSessionStatus.INSTALLED -> {
//...
}
SplitInstallSessionStatus.FAILED -> {
//...
}
else -> {}
}
}
//創(chuàng)建SplitManager 并注冊回調(diào)
val splitManager = SplitInstallManagerFactory.create(requireContext())
splitManager.registerListener(_splitListener)
//安裝語言資源
val request = SplitInstallRequest.newBuilder()
.addLanguage(Locale.forLanguageTag(language))
.build()
splitManager.startInstall(request);
4.2 Dynamic Feature
產(chǎn)品中有一些高級功能,并非所有用戶都會用到,比如某些高級相機特效,卻依賴了比較多的 so 以及底層庫,將它們做成 Dynamic Feature 實現(xiàn)功能的按需加載:
創(chuàng)建 Dynamic Feature 就如同創(chuàng)建一個 Gradle Module。

DF 創(chuàng)建時可以配置兩種下載方式:
- on-demand:是否走動態(tài)下發(fā),如果勾選,表示根據(jù)用戶請求去動態(tài)下載,否則用戶安裝 Apk 時 Module 就會被安裝
- fusing:此配置主要是為了兼容 5.0 以下不支持 AAB 的情況,如果勾選,在 5.0 以下設(shè)備會直接安裝 Module,否則,5.0 以下設(shè)備不包含此 Module
DF 創(chuàng)建后會在 app/build.gradle 中添加響應(yīng)注冊:
dynamicFeatures = [':dynamicfeature']
在需要的場景請求 Dynamic Feature,與請求語言的代碼類似,都是使用 SplitInstallManager:
val splitManager = SplitInstallManagerFactory.create(requireContext())
//動態(tài)安裝模塊
SplitInstallRequest request =
SplitInstallRequest
.newBuilder()
.addModule("FaceLab")
.addModule("Avator")
.build();
splitManager
.startInstall(request)
.addOnSuccessListener { sessionId -> ... }
.addOnFailureListener { exception -> ... }
4.3 Bundletool
AAB 格式?jīng)]法在本地安裝和調(diào)試,通過 Google 提供的 AAB > APK 的打包工具,我們可以在本地編譯成 APK ,便于 QA 的測試和開發(fā)人員的自測。
AAB 生成 APK 的過程如下,中間會生成 .apks ,然后再針對不同設(shè)備生成具體 .apk。

// 通過 aab 生成 apks 文件
bundletool build-apks
--bundle=/MyApp/my_app.aab
--output=/MyApp/my_app.apks
--ks=/MyApp/keystore.jks
--ks-pass=file:/MyApp/keystore.pwd
--ks-key-alias=MyKeyAlias
--key-pass=file:/MyApp/key.pwd
--device-spec=file:device-spec.json
通過 device.json 生成本地 Apk:
bundletool extract-apks
--apks=${apksPath}
--device-spec={deviceSpecJsonPath}
--output-dir={outputDirPath}
也可以直接通過 apks 進行安裝,此時實際上是安裝 apk 到手機上,只是該命令會自動讀取手機配置,然后先生成相應(yīng)的 apk,再安裝到手機。
bundletool install-apks
--apks=/MyApp/my_app.apks
最終的安裝包通過語言等資源以及 Dynamic Feature 的動態(tài)下發(fā),包體積減小近 40%,從 90M+ 壓縮到 55M。
5. ML Kit

除了 Jetpack 的相關(guān)類庫, Google 還為我們的應(yīng)用提供了不少其他技術(shù)支持,比如 ML Kit 。ML Kit 是 Google 推出的針對移動端的一款移動 SDK,支持 Android 與 iOS 平臺,封裝了文字識別、人臉位置檢測、對象跟蹤及檢測等諸多機器學(xué)習(xí)能力,對于機器學(xué)習(xí)開發(fā)者,ML Kit 也同樣提供了 API 幫助開發(fā)者自定義 TensorFlow lite 模型。ML Kit 也支持 Google Play 運行時下發(fā),以減少包體積。
作為一款 AI 特效應(yīng)用,需要支持用戶選擇多人臉圖片中的某個人臉進行渲染,因此人臉檢測能力必不可少,經(jīng)過調(diào)研,我們選擇了 ML Kit 來實現(xiàn)快速人臉檢測。

ML Kit 將幾種機器學(xué)習(xí)能力進行了拆分,App 只需引入需要的能力即可。以人臉檢測為例,引入人臉檢測 Google Play 動態(tài)下發(fā)庫,并使用掛起函數(shù)簡化 API 的使用:
dependencies {
implementation 'com.google.android.gms:play-services-mlkit-face-detection:17.0.0'
}
在 AndroidManifest.xml 文件中進行配置:
<application ...>
...
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="face" />
</application>
使用協(xié)程提供的suspendCancellableCoroutineAPI 將回調(diào)改造成掛起函數(shù)
suspend fun faceDetect(input: Bitmap): List<Face> = suspendCancellableCoroutine { continuation ->
val image = InputImage.fromBitmap(bitmap, 0)
val detector = FaceDetection.getClient()
detector.process(image)
.addOnSuccessListener {
continuation.resumeWith(Result.success(it))
}
.addOnFailureListener {
continuation.resumeWithException(RuntimeException(it))
}
.addOnCanceledListener {
continuation.cancel()
}
}
最后
MAD 幫助我們完成了產(chǎn)品的高效開發(fā)和快速上架,未來我們還會引入 Jetpack Compose 來進一步提升開發(fā)效率,縮短需求迭代周期。受限于篇幅,文中內(nèi)容只是點到為止,希望能夠為其他同類的出海應(yīng)用在技術(shù)選型上提供啟發(fā)和參考。
隨著 Jetpack 為代表的 Google 移動開發(fā)生態(tài)的不斷完善,開發(fā)者們可以將更多精力聚焦到業(yè)務(wù)創(chuàng)新,為廣大用戶開發(fā)出更多豐富的功能。底層技術(shù)的不斷統(tǒng)一,也有利于開發(fā)者們更好地展開技術(shù)交流和共建,擺脫各自為戰(zhàn)、重復(fù)造輪子的開發(fā)窘境。