扣丁書屋

一文讀懂現代 Android 開發最佳實踐

What is MAD?

https://developer.android.com/series/mad-skills

MAD 的全稱是 Modern Android Development,它是一系列技術棧和工具鏈的集合,涵蓋了從編程語言到開發框架等各個環節。

Android 自 08 年誕生之后的多年間 SDK 變化一直不大,開發方式較為固定。13 年起技術更新逐漸加速,特別是 17 年之后, 隨著 Kotlin 及 Jetpack 等新技術的出現 Android 開發方式發生了很大變化,去年推出的 Jetpack Compose 更是將這種變化推向了新階段。Goolge 將這些新技術下的開發方式命名為 MAD ,以此區別于舊有的低效的開發方式。

MAD 可以指導開發者更高效地開發出優秀的移動應用,它的優勢主要體現在以下幾點:

  • 值得信賴:匯聚 Google 在 Android 行業十余年的前沿開發經驗
  • 入門友好:提供大量 Demo 和參考文檔,適用于不同階段不同規模的項目
  • 高效啟動:通過 Jeptack 以及 Jetpack Compose 等框架,可以迅速搭建你的項目
  • 自由選擇:框架豐富多樣,可與傳統語言、原生開發、開源框架自由搭配
  • 體驗一致:不同設備不同版本系統下也具備一致的開發體驗

MAD 助力應用出海

近期我們完成了一款 AI 特效類應用在 GooglePlay 的上架,此應用可將用戶自己的頭像圖片經算法加工成各種藝術效果。應用一經上架便廣受好評,這一切正是得益于我們在項目中對 MAD 技術的綜合運用,我們在最短時間內完成了全部開發,并打造了出色的用戶體驗。

在 MAD 的指導下項目的代碼架構也更加合理、更具可維護性。下圖是項目中 MAD 的整體應用情況:

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

1 . Kotlin

Kotlin 是 Andorid 認可的首選開發語言,我們的項目中,所有代碼都使用 Kotlin 開發。Kotlin 的語法十分簡潔,相對于 Java 同等功能的代碼規??梢詼p少 25%。此外 Kotlin 還具有很多 Java 所不具備的優秀特性:

1.1 Safety

Kotlin 在安全性方面有很多優秀的設計,比如空安全以及數據的不可變性。

Null Safety

Kotlin 的空安全特性讓很多運行時 NPE 提前到編譯期暴露和發現,有效降低線上崩潰的發生。我們在代碼中重視對 Nullable 類型的判斷和處理,我們在數據結構定義時都力求避免出現可空類型,最大限度降低判空成本;

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 這類的命名規范來提醒返回值的可空,Kotlin 通過 ?讓我們可以更好地感知 Nullable 的風險;我們還可以使用 Elvis 操作符 ?: 將 Nullable 轉成 NonNull 便于后續使用;Kotlin 的 !! 讓我們更容易發現 NPE 的潛在風險并可以訴諸靜態檢查給予警告。

Kotlin 的默認參數值特性也可以用來防止 NPE 的出現,像下面這樣的結構體定義,在反序列化等場景中不必擔心 Null 的出現。

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 的安全性還體現在數據不會被隨意修改。我們在代碼中大量使用 data class 并且要求屬性使用 val 而非 var 定義,這有利于單向數據流范式在項目中的推廣,在架構層面實現數據的讀寫分離。

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 有利于對各種請求結果進行枚舉,簡化邏輯。

private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

_uiState.value =
    _uiState.value.copy(bannerList = Result.Success(it))

需要更新 State 時,借助 data class 的 copy 方法可以快捷地拷貝構造一個新實例。

Immutable 還體現在集合類的類型上。我們在項目中提倡非必要不使用 MutableList 這樣的 Mutable 類型,可以減少 ConcurrentModificationException 等多線程問題的發生,同時更重要的是避免了因為 Item 篡改帶來的數據一致性問題:

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 側接收到 UiState 更新通知后,提交 DiffUtil 刷新列表。DiffUtil 正常運作的基礎正是因為 mListnewList 能時刻保持 Immutable 類型。

1.2 Functional

函數在 Kotlin 中是一等公民,可以作為參數或返回值的類型組成高階函數,高階函數可以在集合操作符等場景下提供更加易用的 API。

Collection operations

val bannerImageList: List<BannerImageItem> =
bannerModelList.sortedBy {
    it.bType
}.filter {
    !it.isFrozen()
}.map {
    it.image
}

上面的代碼中我們對 BannerModelList 依次完成排序、過濾,并轉換成 BannerImageItem 類型的列表,集合操作符的使用讓代碼一氣呵成。

Scope functions

作用域函數是一系列 inline 的高階函數。它們可以作為代碼的粘合劑,減少臨時變量等多余代碼的出現。

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

當我們創建并啟動一個 Fragment 時,可以基于作用域函數完成各種初始化工作,就像上面例子那樣。這個例子同時也提醒我們過度使用這些作用域函數(或集合操作符),也會影響代碼的可讀性和可調試性,只有“恰到好處”的使用函數式編程才能真正發揮 Kotlin 的優勢。

1.3 Corroutine

Kotlin 協程讓開發者擺脫了回調地獄的出現,同時結構化并發的特性也有助于對子任務更好地管理,Android 的各種原生庫和三方庫在處理異步任務時都開始轉向 Kotlin 協程。

Suspend function

在項目中,我們倡導使用掛起函數封裝異步邏輯。在數據層 Room 或者 Retorfit 使用掛起函數風格的 API 自不必說,一些表現層邏輯也可以基于掛起函數來實現:

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 用掛起函數處理照片的分享邏輯:彈出分享面板供用戶選擇分享渠道,并將分享結果返回給調用方。調用方啟動分享并同步獲取分享成功或失敗的結果,代碼風格更符合直覺。

Flow

項目中使用 Flow 替代 RxJava 處理流式數據,減少包體積的同時,CoroutineScope 可以有效避免數據泄露:

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()

上面的例子用于從多個數據源獲取 BannerList 。我們增加了磁盤緩存的策略,先請求本地數據庫數據,再請求遠程數據。Flow 的使用可以很好地滿足這類涉及多數據源請求的場景。而另一面在調用側,只要提供合適的 CoroutineScope 就不必擔心泄露的發生。

1.4 KTX

一些原本基于 Java 實現的 Android 庫通過 KTX 提供了針對 Kotlin 的擴展 API,讓它們在 Kotlin 工程中更容易地被使用。

我們的項目使用 Jetpack Architecture Components 搭建 App 基礎架構,KTX 幫助我們大大降低了 Kotlin 項目中的 API 使用成本,舉幾個最常見的 KTX 的例子:

fragment-ktx

fragment-ktx 提供了一些針對 Fragment 的 Kotlin 擴展方法,比如 ViewModel 的創建:

class HomeFragment : Fragment() {
    private val homeViewModel : HomeViewModel by viewModels()
    ...
}

相對于 Java 代碼在 Fragment 中創建 ViewMoel 變得極其簡單,其背后的是現實活用了各種 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 關鍵字在運行時獲取泛型類型用來創建具體 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 關鍵字創建 ViewModel,這里借助 Kotlin 的代理特性實現了實例的延遲創建。

viewmodle-ktx

viewModel-ktx 提供了針對 ViewModel 的擴展方法, 例如 viewModelScope,可以隨著 ViewModel 的銷毀及時終止過期的異步任務,讓 ViewModel 更安全地作為數據層與表現層之間的橋梁使用。

viewModelScope.launch {
    //監聽數據層的數據
    repo.getMessage().collect {
        //向表現層發送消息
        _messageFlow.emit(message)
    }
}

實現原理也非常簡單:

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 本質上是 ViewModle 的擴展屬性,通過 custom get 創建 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 并被調用 close 以取消 SupervisorJob ,終止所有子協程。KTX 活用了 Kotlin 的各種特性和語法糖 ,后面 Jetpack 章節會看到更多 KTX 的使用。

2 . Android Jetpack

Android 通過 Jetpack 為開發者提供 AOSP 之上的基礎能力支持,其范圍覆蓋了從 UI 到 Data 各個層級,降低了開發者們自造輪子的需求。近期 Jetpack 組件的架構規范又進行了全面升級,幫助我們在開發過程中能更好地貫徹關注點分離這一設計目標。

2.1 Architecture

Android 倡導表現層和數據層分離的架構設計,并使用單向數據流(Unidirectional Data Flow)完成數據通信。Jetpack 通過一系列 Lifecycle-aware 的組件支持了 UDF 在 Android 中的落地。

UDF 的主要特點和優勢如下:

  • 唯一真實源(SSOT):UI State 在 ViewModel 集中管理,降低了多數據源之間的同步成本
  • 數據自上而下流動:UI 的更新來 VM 的狀態變化,UI 自身不持有狀態、不耦合業務邏輯
  • 事件自下而上傳遞:UI 發送 event 給 VM 對狀態集中修改,狀態變化可回溯、利于單測

項目中凡是涉及 UI 的業務場景都是基于 UDF 打造的。以 HomePage 為例,其中包括 BannerListContentList 兩組數據展示,所有的數據集中管理在 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 獲取數據并更新 UiState,View 訂閱此狀態并刷新 UI。viewModelScope.launch 提供的 CoroutineScope 可以隨著 ViewModel 的 onClear 結束運行中的協程,避免泄露。

數據層我們使用 Repository Pattern 封裝本地數據源和遠程數據源的具體實現:

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 為例,先從數據庫請求本地數據加速顯示,然后再請求遠程數據源更新數據,同時進行持久化,便于下次請求。

UI 層的邏輯很簡單,訂閱 ViewModel 的數據并刷新 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 前后臺切換時自動停止數據流的發射,節省資源開銷。

2.2 Navigation

作為“單 Activity 架構”的實踐者,我們選擇了使用 Jetpack Navigation 作為 App 的導航組件。Navigation 組件實現了導航設計原則,為跨應用切換或應用內頁面間的切換提供了一致的用戶體驗,并且提供了各種優勢,包括:

  • 處理 Fragment 事務;
  • 默認情況下,正確處理往返操作;
  • 為動畫和轉場提供標準化資源;
  • 實現和處理深層鏈接;
  • 包括導航界面模式(例如抽屜式導航欄和底部導航),開發者只需完成極少的額外工作;
  • 提供 Gradle 插件用以保證在不同頁面傳遞參數時類型安全;
  • 提供了導航圖范圍的 ViewModel,以在同導航圖內的頁面進行數據共享;

Navigation 提供了 XML 以及 Kotlin DSL 兩種配置方式。我們在項目中發揮 Kotin 的優勢,基于類型安全的 DSL 創建導航圖,同時通過函數提取為頁面統一指定轉場動畫:

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

//統一指定轉場動畫
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 中,調用 initGraph() 為 Root Fragment 初始化導航圖:

@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() 可以隨時基于當前 Destination 進行正確地頁面跳轉:

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

除此以外,我們可以聲明全局頁面導航,這種方式在引導用戶登錄注冊或前往反饋頁等場景有很大用處:

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 工程中的常用的技術,依賴注入作為控制反轉設計原則的一種實現方式,有利于實例的生產側與消費側的解耦,踐行了關注點分離的設計原則,也更有助于單元測試的編寫。

Hilt 在 Dagger 的基礎上構建而成,繼承了 Dagger 編譯時檢查、運行時高性能、可伸縮等優點的同時提供了更友好的 API ,使得 Dagger 使用成本大幅降低。Android Studio 也內置了對 Dagger/Hilt 的支持,后文會介紹。

項目中大量使用了 Hilt 完成依賴注入,進一步提升了代碼的編寫效率。我們使用 @Singleton 提供 Repository 的單例實現,當 Repository 需要 Context 來創建 SharedPreferences 或者 DataStore 時,使用 @ApplicationContext 注解傳入應用級別的 Context,在需要的地方只需要 @Inject 即可注入對象:

@AndroidEntryPoint
class RecommendFragment : Fragment() {
    @Inject
    lateinit var recommendRepository: RecommendRepository

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        recommendRepository.doSomeThing()
    }
}

對于一些無法在構造函數中增加注解的三方庫的類,我們可以使用 @Provides 來告訴 Hilt 如何創建相關實例。例如提供創建 Retorfit API 的實現,省去每次手動創建的工作。

@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 庫,所謂持久性工作指可以跨越應用或者系統重啟持續執行的任務,比如應用數據與服務器之間進行同步,或者是上傳日志等。WorkManager 對內會根據策略自動選擇 FirebaseJobDispatcher、GcmNetworkManagerJobScheduler 等執行調度任務,對外則提供了簡單一致的 API 方便使用。

WorkManager 默認使用 Jetpack StartUp 庫進行初始化,開發者只需關注定義與實現 Worker 即可,無需其他額外工作。WorkManager 向后兼容到 Android 6.0 、覆蓋了市面上絕大多數的機型,可以有效取代 Service 完成那些需要長期執行的后臺任務。

產品為了減少用戶生成頭像時上傳圖片所需時間與流量消耗,會在上傳之前對圖片進行壓縮,但是壓縮過程的臨時文件會增加 App 所占存儲空間,所以我們使用 WorkManager 對清理壓縮圖片緩存的工作進行調度,在 App 啟動后將任務提交給 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()
        }
    }
}

還有一種場景是用戶下載圖片。下載需要網絡,并且此工作的優先級比較高,因此可以使用 WorkManager 提供的工作約束以及加急工作 (WorkManager 2.7 及以上) 等能力,除此以外還可以對工作的結果信息進行監聽,以對用戶進行提示:

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

應用啟動時需要做大量初始化工作,例如 SDK 的初始化、基礎模塊的配置等。StartUp 出現之前我們使用 ContentProvider 完成“無侵”的初始化,避免 init(Context) 這類代碼在 Application 中的出現。但是 ContentProvider 的創建成本較高,多個 ContentProvider 同時創建會拖慢應用啟動速度且初始化時序不可控。

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 模塊初始化完成后才會繼續。

2.6 Room

local-first 架構的 App 可以提供良好的用戶體驗,當設備無法訪問網絡時,用戶仍可在離線狀態下瀏覽相應內容。Android 提供了 SQLite 作為訪問數據庫的 API,但是 SQLite API 比較底層,需要人工確保 SQL 語句的正確性,除此以外,還需要編寫大量的模板代碼來完成 PO 與 DO 之間的轉換。Jetpack Room 在 SQLite 的基礎上提供了一個抽象層,幫助開發者更流暢的訪問數據庫。

Room 主要包含 3 個組件:Database 是數據庫持有者,是與底層數據庫連接的主要接入點;Entity 代表數據庫中的表;DAO 包含用于訪問數據庫的方法。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
}

需要注意的是創建數據庫的成本比較高,所以單進程 App 內要保證數據庫為單例:

@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()
    }

}

當數據庫中的數據發生更新時,我們希望 UI 也能隨之自動刷新。得益于 Room 對 Coroutine 以及 RxJava 良好的支持,只需要引入 room-ktx 庫或者 room-rxjava2/3 庫,DAO 中的方法也可以直接返回 Flow 或者 Observable,或者直接使用掛起函數:

@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 進行訂閱,便可以做到當數據庫內容更新時 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)
        }
    }
}

更進一步,在 UI 層我們只訂閱數據庫中的數據,而在后臺使用 WorkManager 發起網絡請求,獲取到數據后再將最新的數據寫入到數據庫中。由于數據庫訪問速度遠遠快于網絡,因此頁面可以更快的呈現給用戶。

3 . Android Studio

Android Studio 誕生至今一直保持著活躍的版本更新,當前最新版本已經更新至 Bumblebee | 2021.1.1.21 ,自 4.3 Canary 1 以來 Android Studio 在命名風格上有所調整,更好的對齊了 IntelliJ 平臺版本。除了定期發布的穩定版,開發者還可以通過 RC 和 Preview 版本提前體驗更多新鮮功能。

隨著版本的不斷更新,編寫和調試代碼的體驗得到持續的優化,且集成了越來越多的新功能。Layout Instpector ,Device Exploer 等既有功能自不必說,以下這些新特性也為我們的開發、調試提供了巨大的便利。

3.1 Database Inspector

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

3.2 Realtime Profilers

Android Studio 的 Realtime Profilers 工具可以幫助我們在如下四個方面監測和發現問題,有時在缺少工程代碼的情況下通過 Memory Profilers 還可以查看其內部的實例和變量細節。

  • CPU:性能剖析器檢查 CPU 活動,切換到 Frames 視圖還可以界面卡頓追蹤
  • Memory:識別可能會導致應用卡頓、凍結甚至崩潰的內存泄漏和內存抖動,可以捕獲堆轉儲、強制執行垃圾回收以及跟蹤內存分配以定位內存方面的問題
  • Battery:會監控 CPU、網絡無線裝置和 GPS 傳感器的使用情況,并直觀地顯示其中每個組件消耗的電量,了解應用在哪里耗用了不必要的電量
  • Network:顯示實時網絡活動,包括發送和接收的數據以及當前的連接數。這便于您檢查應用傳輸數據的方式和時間,并適當優化代碼

3.3 APK Analyzer

Apk 的下載會耗費網絡流量,安裝了還會占用存儲空間。其體積的大小會對 App 安裝和留存產生影響,分析和優化其體積顯得尤為必要。

借助 AS 的 APK Analyzer 可以幫助完成如下幾項工作:

  • 快速分析 Apk 構成,包括 DEX、Resources 和 Manifest 的 Size 和占比,助力我們優化代碼或資源的方向
  • Diff Apk 以了解版本的前后差異,精準定位體積變大的源頭
  • 分析其他 Apk,包括查看大致的資源和分析代碼邏輯,進而拆解、Bug 定位

3.4 DI Navigation

依賴注入有助于模塊間的解耦,踐行了關注點分離的設計原則。我們使用 Dagger / Hilt 通過編譯期代碼生成隱藏了相關具體實現,這在降低構建依賴關系圖的成本的同時,也增加了開發者調試代碼的成本:尋找被注入實例的來源變得困難起來。

如今 Android Studio 幫開發者解決了這個痛點。自 4.1 我們可以在基于 Dagger 的代碼(例如 Components,Subcomponents,Modules 等)中跳轉,找尋依賴關系。

在 Dagger 或 Hilt 相關的代碼旁可以看到下面的 icon:

點擊左側 icon 可以跳轉到實例對象的提供處,點擊右側 icon 則可以跳轉到對象的使用處,當有多處使用時則會給出候選列表供選擇。

Android 4.2 起還增加了對 @EnterPoint 的依賴查詢,對于 ContentProvider 這樣的不能自動注入的組件,也可以通過 Hilt 擴大依賴注入的使用范圍。

4 . App Bundle

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

Split APKs 機制是 AAB 實現動態下發的基礎,AAB 上傳 GP 后被拆分成一個 base APK 和多個 Split APKs。首次下載只下發 Base APK,然后根據使用場景動態下發 Split APKs。Split 可以使 Configuration APKs ,也可以是一個 Dynamic Features APKs:

  • Configuration APKs:根據 language,density,abi 三個維度拆分資源,比如 res/drawable-xhdpi 會被拆分到 xhdpi 的 Apk 中,res/values-en 會被拆分到 en 的 apk 中,當 Configurations Changed 發生時請求必要資源
  • Dynamic Features APKs:可以實現 Feature 的按需動態加載,這類似于國內流行的“插件化”技術,通過將一些非常用的功能做成 Dynamic Feature 可以實現功能的按需加載。

Google 重視 AAB 格式的推廣,自 21 年 8 月起,規定新 App 必須使用 AAB 格式才能在 Google Play 上架。作一款要在海外上架的產品,我們自然也選擇了 AAB 的交付方式,除了在包體積方面的顯著受益,也較好地助力了產品推廣和裝機率的提升。

4.1 Language Split

我們的應用在多個國家同時上架,需要支持英語、印尼語、葡語等多種語言,借助 AAB 可以避免下載其他國家的語言資源。

語言動態下發非常簡單,首先在 Gradle 開啟 language 的 enableSplit

bundle {
    language {
        enableSplit = true
    }
}

切換系統語言時,應用會通過 GP 自動下載所需的語言。當然也可以根據業務需求手動請求語言資源,比如在我們內置的語言切換界面中選擇其他語言時:

private val _splitListener = SplitInstallStateUpdatedListener { state ->
    val lang = if (state.languages().isNotEmpty())
                state.languages().first() else ""
    when (state.status()) {
        SplitInstallSessionStatus.INSTALLED -> {
            //...
        }
        SplitInstallSessionStatus.FAILED -> {
            //...
        }
        else -> {}
    }
}

//創建SplitManager  并注冊回調
val splitManager = SplitInstallManagerFactory.create(requireContext())
splitManager.registerListener(_splitListener)


//安裝語言資源
val request = SplitInstallRequest.newBuilder()
    .addLanguage(Locale.forLanguageTag(language))
    .build()
splitManager.startInstall(request);

4.2 Dynamic Feature

產品中有一些高級功能,并非所有用戶都會用到,比如某些高級相機特效,卻依賴了比較多的 so 以及底層庫,將它們做成 Dynamic Feature 實現功能的按需加載:

創建 Dynamic Feature 就如同創建一個 Gradle Module。

DF 創建時可以配置兩種下載方式:

  • on-demand:是否走動態下發,如果勾選,表示根據用戶請求去動態下載,否則用戶安裝 Apk 時 Module 就會被安裝
  • fusing:此配置主要是為了兼容 5.0 以下不支持 AAB 的情況,如果勾選,在 5.0 以下設備會直接安裝 Module,否則,5.0 以下設備不包含此 Module

DF 創建后會在 app/build.gradle 中添加響應注冊:

dynamicFeatures = [':dynamicfeature']

在需要的場景請求 Dynamic Feature,與請求語言的代碼類似,都是使用 SplitInstallManager

val splitManager = SplitInstallManagerFactory.create(requireContext())

//動態安裝模塊
SplitInstallRequest request =
    SplitInstallRequest
        .newBuilder()
        .addModule("FaceLab")
        .addModule("Avator")
        .build();

splitManager
    .startInstall(request)
    .addOnSuccessListener { sessionId -> ... }
    .addOnFailureListener { exception -> ... }

4.3 Bundletool

AAB 格式沒法在本地安裝和調試,通過 Google 提供的 AAB > APK 的打包工具,我們可以在本地編譯成 APK ,便于 QA 的測試和開發人員的自測。

AAB 生成 APK 的過程如下,中間會生成 .apks ,然后再針對不同設備生成具體 .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 到手機上,只是該命令會自動讀取手機配置,然后先生成相應的 apk,再安裝到手機。

bundletool install-apks
--apks=/MyApp/my_app.apks

最終的安裝包通過語言等資源以及 Dynamic Feature 的動態下發,包體積減小近 40%,從 90M+ 壓縮到 55M。

5 . ML Kit

除了 Jetpack 的相關類庫, Google 還為我們的應用提供了不少其他技術支持,比如 ML Kit 。ML Kit 是 Google 推出的針對移動端的一款移動 SDK,支持 Android 與 iOS 平臺,封裝了文字識別、人臉位置檢測、對象跟蹤及檢測等諸多機器學習能力,對于機器學習開發者,ML Kit 也同樣提供了 API 幫助開發者自定義 TensorFlow lite 模型。ML Kit 也支持 Google Play 運行時下發,以減少包體積。

作為一款 AI 特效應用,需要支持用戶選擇多人臉圖片中的某個人臉進行渲染,因此人臉檢測能力必不可少,經過調研,我們選擇了 ML Kit 來實現快速人臉檢測。

ML Kit 將幾種機器學習能力進行了拆分,App 只需引入需要的能力即可。以人臉檢測為例,引入人臉檢測 Google Play 動態下發庫,并使用掛起函數簡化 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>

使用協程提供的suspendCancellableCoroutineAPI 將回調改造成掛起函數

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 幫助我們完成了產品的高效開發和快速上架,未來我們還會引入 Jetpack Compose 來進一步提升開發效率,縮短需求迭代周期。受限于篇幅,文中內容只是點到為止,希望能夠為其他同類的出海應用在技術選型上提供啟發和參考。

隨著 Jetpack 為代表的 Google 移動開發生態的不斷完善,開發者們可以將更多精力聚焦到業務創新,為廣大用戶開發出更多豐富的功能。底層技術的不斷統一,也有利于開發者們更好地展開技術交流和共建,擺脫各自為戰、重復造輪子的開發窘境。


https://mp.weixin.qq.com/s/h8DewFuruXBc9pQmXcNtfw

最多閱讀

簡化Android的UI開發 2年以前  |  515113次閱讀
Android 深色模式適配原理分析 1年以前  |  26416次閱讀
Android 樣式系統 | 主題背景覆蓋 1年以前  |  7953次閱讀
Android Studio 生成so文件 及調用 1年以前  |  5587次閱讀
30分鐘搭建一個android的私有Maven倉庫 3年以前  |  4751次閱讀
Android設計與開發工作流 2年以前  |  4413次閱讀
Google Enjarify:可代替dex2jar的dex反編譯 3年以前  |  4397次閱讀
Android多渠道打包工具:apptools 3年以前  |  4028次閱讀
移動端常見崩潰指標 2年以前  |  4014次閱讀
Google Java編程風格規范(中文版) 3年以前  |  3942次閱讀
Android-模塊化-面向接口編程 1年以前  |  3857次閱讀
Android內存異常機制(用戶空間)_NE 1年以前  |  3824次閱讀
Android UI基本技術點 3年以前  |  3790次閱讀
Android死鎖初探 2年以前  |  3734次閱讀

手機掃碼閱讀
18禁止午夜福利体验区,人与动人物xxxx毛片人与狍,色男人窝网站聚色窝
<蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <文本链> <文本链> <文本链> <文本链> <文本链> <文本链>