離線優先應用程式是指不必存取網際網路,就能執行所有或部分關鍵核心功能的應用程式。也就是說,這類應用程式可以離線執行部分或所有商業邏輯。

建構離線優先應用程式時,首先要考慮用於存取應用程式資料和商業邏輯的 資料層 。應用程式可能需要不時從裝置外部來源重新整理資料。執行此操作時,應用程式可能需要呼叫網路資源來保持最新狀態。

然而,我們無法保證隨時都能使用網路。裝置難免會遇到網路連線不穩或緩慢的問題,使用者也可能遇到以下情況:

  • 網際網路頻寬受限。
  • 連線暫時中斷,例如搭乘電梯或經過隧道時。
  • 偶爾才能存取資料。例如使用僅支援 Wi-Fi 上網的平板電腦。
  • 不管原因為何,應用程式通常都能在上述情況下妥善運作。為確保應用程式可在離線狀態下正確運作,應用程式應符合以下條件:

  • 即使沒有穩定的網路連線也能使用。
  • 會立即向使用者顯示本機資料,而非靜靜等待第一個網路呼叫完成或失敗。
  • 擷取資料的方式應將電池和資料狀態納入考量。例如,只在充電或連上 Wi-Fi 等理想情況下要求擷取資料。
  • 符合上述條件的應用程式通常稱為離線優先應用程式。

    資料層中的 存放區 會負責合併資料來源,提供應用程式資料。在離線優先應用程式中,須至少有一個資料來源不必存取網路,就能執行最關鍵的工作,比如讀取資料。

    在離線優先應用程式中使用模型資料

    在離線優先應用程式中,每個會用到網路資源的存放區都至少有 2 個資料來源:

  • 本機資料來源
  • 網路資料來源
  • 本機資料來源

    本機資料來源是應用程式標準化的 可靠資料來源 。當應用程式中的較高層讀取任何資料時,都應將此做為專屬來源。這種做法可確保連線狀態之間的資料維持一致。一般來說,本機資料來源是由保存在磁碟中的儲存空間負責備份。以下列舉將資料保留至磁碟的一些常見方式:

  • 結構化資料來源,例如 Room 等關聯資料庫。
  • 非結構化資料來源,例如帶有 Datastore 的通訊協定緩衝區。
  • 簡易檔案。
  • 網路資料來源

    網路資料來源是應用程式的實際狀態。本機資料來源最好能與網路資料來源同步,但也可以落後。如果落後了,應用程式需要在恢復連線時更新。相反地,在連線恢復且應用程式可以更新網路資料來源前,網路資料來源也可能落後於本機資料來源。應用程式的網域和 UI 層一律不應與網路層直接通訊,而應由代管的 repository 負責通訊,並將該網路層用於更新本機資料來源。

    應用程式讀取及寫入本機和網路資料來源時,採取的方式存在根本差異。查詢本機資料來源既快速又有彈性,使用 SQL 查詢時便是如此。反之,網路資料來源可能較慢且受限。透過 ID 以漸進方式存取符合 REST 樣式的資源時,就屬於這種情況。因此,每種資料來源通常需要對自身提供的資料採用專屬的表示法,本機資料來源和網路資料來源也可能有自己的模型。

    下方目錄結構以視覺化方式呈現這個概念。 AuthorEntity 代表從應用程式本機資料庫讀取的作者,而 NetworkAuthor 代表透過網路序列化的作者:

    data/
    ├─ local/
    │ ├─ entities/
    │ │ ├─ AuthorEntity
    │ ├─ dao/
    │ ├─ NiADatabase
    ├─ network/
    │ ├─ NiANetwork
    │ ├─ models/
    │ │ ├─ NetworkAuthor
    ├─ model/
    │ ├─ Author
    ├─ repository/
    

    下方則是 AuthorEntityNetworkAuthor 的詳細資料:

    * Network representation of [Author] @Serializable data class NetworkAuthor( val id: String, val name: String, val imageUrl: String, val twitter: String, val mediumPage: String, val bio: String, * Defines an author for either an [EpisodeEntity] or [NewsResourceEntity]. * It has a many-to-many relationship with both entities @Entity(tableName = "authors") data class AuthorEntity( @PrimaryKey val id: String, val name: String, @ColumnInfo(name = "image_url") val imageUrl: String, @ColumnInfo(defaultValue = "") val twitter: String, @ColumnInfo(name = "medium_page", defaultValue = "") val mediumPage: String, @ColumnInfo(defaultValue = "") val bio: String,

    建議您同時將 AuthorEntityNetworkAuthor 保留在資料層,並公開第三種類型供外部層使用。假如本機和網路資料來源中的細微變更並未徹底改變應用程式行為,上述做法可確保外部層不受這些變更影響。詳情請參閱以下程式碼片段:

    * External data layer representation of a "Now in Android" Author data class Author( val id: String, val name: String, val imageUrl: String, val twitter: String, val mediumPage: String, val bio: String,

    網路模型可定義擴充功能方法,並將其轉換成本機模型,而本機模型同樣可定義擴充功能方法,並將其轉換為外部表示法,如下所示:

    * Converts the network model to the local model for persisting * by the local data source fun NetworkAuthor.asEntity() = AuthorEntity( id = id, name = name, imageUrl = imageUrl, twitter = twitter, mediumPage = mediumPage, bio = bio, * Converts the local model to the external model for use * by layers external to the data layer fun AuthorEntity.asExternalModel() = Author( id = id, name = name, imageUrl = imageUrl, twitter = twitter, mediumPage = mediumPage, bio = bio,

    「讀取」是在離線優先應用程式中對應用程式資料執行的基本作業。因此,請務必確保應用程式能讀取資料,並在一有新資料時就能加以顯示。能這麼做的就是回應式 應用程式 ,因為這類應用程式會公開具有可觀察類型的讀取 API。

    在以下程式碼片段中, OfflineFirstTopicRepository 會為自身的所有讀取 API 傳回 Flows 。如此一來,當它收到來自網路資料來源的更新內容時,就能更新自己的讀取器。換句話說,如果本機資料來源無效,讀取器就會讓 OfflineFirstTopicRepository 推送變更。因此,您必須備妥 OfflineFirstTopicRepository 的所有讀取器,在應用程式恢復網路連線時,處理可能觸發的資料變更。此外, OfflineFirstTopicRepository 還會直接從本機資料來源讀取資料,但它只能先更新本機資料來源,進而將資料變更的消息告知讀取器。

    class OfflineFirstTopicsRepository(
        private val topicDao: TopicDao,
        private val network: NiaNetworkDataSource,
    ) : TopicsRepository {
        override fun getTopicsStream(): Flow<List<Topic>> =
            topicDao.getTopicEntitiesStream()
                .map { it.map(TopicEntity::asExternalModel) }
    

    錯誤處理策略

    離線優先應用程式中處理錯誤的方式各不相同,具體取決於可能發生錯誤的資料來源。以下各小節將概略說明這些策略。

    本機資料來源

    從本機資料來源讀取資料時,發生錯誤的機率應該極低。為防止讀取器出錯,請在讀取器收集資料時所用的 Flows 上使用 catch 運算子。

    ViewModel 中使用 catch 運算子的步驟如下:

    class AuthorViewModel(
        authorsRepository: AuthorsRepository,
    ) : ViewModel() {
       private val authorId: String = ...
       // Observe author information
        private val authorStream: Flow<Author> =
            authorsRepository.getAuthorStream(
                id = authorId
            .catch { emit(Author.empty()) }
    

    網路資料來源

    如果從網路資料來源讀取資料時發生錯誤,應用程式需運用經驗法則,重新嘗試擷取資料。常見的經驗法則如下:

    指數輪詢 中,應用程式會不斷嘗試從網路資料來源讀取資料,每次嘗試的時間間隔會持續增加,直到讀取成功或其他條件指示應停止讀取為止。

    圖 2 :以指數輪詢方式讀取資料

    以下條件可用來評估應用程式是否應繼續延遲作業:

  • 網路資料來源指出的錯誤類型。舉例來說,如果網路呼叫傳回的錯誤指出沒有連線,就應重試網路呼叫。相反地,如果 HTTP 要求未獲得授權,那麼在取得適當憑證前,便不該重試 HTTP 要求。
  • 重試次數上限。
  • 網路連線監控

    在這個做法中,讀取要求會排入佇列,直到應用程式確定可連線至網路資料來源為止。建立連線後,系統會將讀取要求移出佇列,然後讀取資料並更新本機資料來源。在 Android 上,系統可能會透過 Room 資料庫來維護這個佇列,並以持續性工作的形式,運用 WorkManager 清空佇列。

    圖 3 :使用網路監控功能讀取佇列

    我們建議在離線優先應用程式中讀取資料時使用可觀察的類型,不過對寫入 API 來說,則建議使用暫停函式等 非同步 API 。這可避免阻斷 UI 執行緒,並協助處理錯誤,因為在跨越網路邊界時,離線優先應用程式中的寫入作業可能會失敗。

    interface UserDataRepository {
         * Updates the bookmarked status for a news resource
        suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
    

    在上方的程式碼片段中,由於上述方法暫停,選用的非同步 API 為協同程式

    在離線優先應用程式中寫入資料時,可以考慮採用以下三種策略,具體選擇的策略取決於寫入的資料類型和應用程式需求:

    僅限線上寫入

    嘗試橫跨網路邊界寫入資料。如果成功,請更新本機資料來源,否則請擲回例外狀況,交由呼叫端妥善回覆。

    圖 4:僅限線上寫入

    這項策略通常用於必須近乎即時地在線上執行的寫入交易,例如銀行轉帳。由於寫入可能失敗,因此通常必須告知使用者無法寫入,或事先禁止使用者嘗試寫入資料。在這類情況下,您或許可使用以下策略:

  • 如果應用程式需要具備網際網路存取權才能寫入資料,或許會選擇不向使用者顯示可寫入資料的 UI,或者至少停用這項功能。
  • 您可以採用使用者無法關閉的彈出式視窗訊息,或透過臨時提示,通知使用者目前處於離線狀態。
  • 已加入佇列的寫入作業

    如有要寫入的物件,請將該物件插入佇列。接著在應用程式恢復連線後,繼續以指數輪詢的方式清空佇列。在 Android 上,清空離線佇列屬於持續性工作,通常會委派給 WorkManager

    圖 5:透過重試寫入佇列

    此做法適合以下情況:

  • 不一定要將資料寫入網路。
  • 交易不具時效性。
  • 作業失敗時,不一定要告知使用者。
  • 此做法的用途包括分析事件和記錄。

    請先寫入本機資料來源,然後將寫入作業排入佇列,方便盡快通知網路。這一點非常重要,因為當應用程式恢復網路連線後,網路資料來源和本機資料來源可能會發生衝突。下一節將詳細說明如何解決衝突。

    圖 6:延遲寫入

    如果資料對應用程式而言非常重要,此做法就是不二之選。舉例來說,在待辦事項清單的離線優先應用程式中,使用者離線新增的所有工作都必須儲存在本機,避免資料遺失。

    同步處理及衝突解決

    離線優先應用程式恢復連線時,需要核對本機與網路資料來源中的資料。這項程序稱為同步處理。應用程式主要是透過兩種方式與網路資料來源保持同步:

  • 提取式同步處理
  • 推送式同步處理
  • 提取式同步處理

    在提取式同步處理作業中,應用程式會連上網路,按需求讀取最新的應用程式資料。這種做法的常見經驗法則是以導覽為基礎,採用此做法時,應用程式只會在向使用者顯示資料前擷取資料。

    如果應用程式預計在短期到中期內都沒有網路連線,這個做法就非常實用。這是因為重新整理資料需要見機行事,在長期沒有連線的情況下,使用者越有可能利用過時或空白的快取,嘗試前往應用程式目的地。

    圖 7:提取式同步處理:裝置 A 只存取畫面 A 和 B 的資源,裝置 B 只存取畫面 B、C 和 D 的資源

    假設在某個應用程式中,網頁權杖的用途是針對特定畫面擷取無盡捲動清單內的項目。這個實作方法可能會延遲連上網路、將資料保存至本機資料來源,然後從本機資料來源讀取資料,將資訊傳回給使用者。如果沒有網路連線,存放區可能只會從本機資料來源要求資料。這是 Jetpack Paging 程式庫搭配 RemoteMediator API 使用的模式。

    class FeedRepository(...) {
        fun feedPagingSource(): PagingSource<FeedItem> { ... }
    class FeedViewModel(
        private val repository: FeedRepository
    ) : ViewModel() {
        private val pager = Pager(
            config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false
            remoteMediator = FeedRemoteMediator(...),
            pagingSourceFactory = feedRepository::feedPagingSource
        val feedPagingData = pager.flow
    

    下表摘要說明提取式同步處理的優點和缺點:

    實作方式相對簡單。 容易大量使用資料。這是因為重複造訪導覽目的地會觸發不必要的擷取作業,重新擷取未變更的資訊。您可以透過適當的快取緩解這個問題,比如在 UI 層使用 cachedIn 運算子,或在網路層使用 HTTP 快取。 系統一律不會擷取不需要的資料。 使用關聯資料時無法正確擴充,因為提取的模型必須自給自足。如果正在同步處理的模型仰賴其他待擷取的模型來填入內容,前述大量使用資料的問題會更加嚴重。此外,這也可能導致父項模型與巢狀模型的存放區彼此依賴。

    推送式同步處理

    在推送式同步處理作業中,本機資料來源會盡可能嘗試模仿網路資料來源的備用資源組合。首次啟動時,本機資料來源會主動擷取適量資料來設定基準,之後便會運用來自伺服器的通知,在資料過時當下發出快訊。

    圖 8:推送式同步處理:網路在資料變更時通知應用程式,應用程式則透過擷取已變更的資料來回應

    收到過時通知後,應用程式會連線至網路,只更新標示為過時的資料。這項工作會委派給 Repository,由其負責連線至網路資料來源,並保留擷取到本機資料來源的資料。由於存放區會經由可觀察的類型公開資料,因此一旦有任何變更,讀取器就會收到通知。

    class UserDataRepository(...) {
        suspend fun synchronize() {
            val userData = networkDataSource.fetchUserData()
            localDataSource.saveUserData(userData)
    

    在這個做法中,應用程式會大幅減少對網路資料來源的依賴,即使長時間沒有這類資料來源也能運作。這會在離線時一併提供讀取及寫入存取權,因為系統假設本機存有來自網路資料來源的最新資訊。

    下表摘要說明推送式同步處理的優點和缺點:

    混合式同步處理

    部分應用程式會採用混合做法,依資料選擇提取式或推送式做法。舉例來說,由於動態消息的更新頻率較高,社群媒體應用程式可能會採用提取式同步處理做法,按需求擷取使用者追蹤的動態消息。但這個社群媒體應用程式在處理使用者名稱、個人資料相片等已登入使用者的資料時,也可能選擇採用推送式同步處理做法。

    歸根究底,在為離線優先應用程式選擇同步處理方式時,需考量產品需求和現有的技術基礎架構。

    如果應用程式在離線期間寫入本機的資料與網路資料來源不相符,就表示發生衝突,而您必須先解決衝突,才能執行同步處理作業。

    如要解決衝突,通常需要藉助版本管理。應用程式會需要執行一些簿記工作,記錄發生變更的時間,進而將中繼資料傳遞給網路資料來源。接著,網路資料來源會負責提供絕對可靠的資料來源。視應用程式需求而定,可以考慮的衝突解決策略十分多樣。對行動應用程式而言,常見做法是「以最後寫入者為準」。

    以最後寫入者為準

    在這個做法中,裝置會在寫入網路的資料中附加時間戳記中繼資料。網路資料來源收到這些資料時,會捨棄比目前狀態舊的所有資料,同時接受比目前狀態新的資料。

    圖 9:「以最後寫入者為準」。資料的可信來源取決於最後一個寫入資料的實體

    在上圖中,兩部裝置都處於離線狀態,而且最初都與網路資料來源保持同步。離線時,兩者都會在本機寫入資料,並記錄寫入資料的時間。如果兩者皆再次連上網路並與網路資料來源同步,為解決衝突問題,網路會保留裝置 B 的資料,因為該裝置寫入資料的時間較晚。

    在離線優先應用程式中使用 WorkManager

    在上文介紹的讀取和寫入策略中,有兩種常見的公用程式:

  • 讀取:用來將讀取作業延遲到有網路連線為止。
  • 寫入:用來將寫入作業延遲到有網路連線為止,並將寫入作業重新排入佇列,方便重試。
  • 網路連線監控器
  • 讀取:當做信號,指示在應用程式連線時清空讀取佇列,並用於同步處理
  • 寫入:當做信號,指示在應用程式連線時清空寫入佇列,並用於同步處理
  • 這兩種情況都是說明 WorkManager 擅長處理持續性作業的例子。例如在 Now in Android 範例應用程式中,同步處理本機資料來源時,WorkManager 可用做讀取佇列和網路監控器。啟動期間,應用程式會執行下列操作:

  • 將讀取同步處理工作排入佇列,確保本機與網路資料來源維持一致。
  • 清空讀取同步處理佇列,在應用程式連上網路時開始同步。
  • 使用指數輪詢功能從網路資料來源執行讀取作業。
  • 將讀取結果保留在本機資料來源,解決任何可能出現的衝突。
  • 公開來自本機資料來源的資料,供應用程式的其他層取用。
  • 上述過程如下圖所示:

    圖 10:Now in Android 應用程式中的資料同步作業

    透過 WorkManager 將同步處理工作加入佇列後,請使用 KEEP ExistingWorkPolicy 將其指定為不重複工作

    class SyncInitializer : Initializer<Sync> {
       override fun create(context: Context): Sync {
           WorkManager.getInstance(context).apply {
               // Queue sync on app startup and ensure only one
               // sync worker runs at any time
               enqueueUniqueWork(
                   SyncWorkName,
                   ExistingWorkPolicy.KEEP,
                   SyncWorker.startUpSyncWork()
           return Sync
    

    其中 SyncWorker.startupSyncWork() 定義如下:

    Create a WorkRequest to call the SyncWorker using a DelegatingWorker. This allows for dependency injection into the SyncWorker in a different module than the app module without having to create a custom WorkManager configuration. fun startUpSyncWork() = OneTimeWorkRequestBuilder<DelegatingWorker>() // Run sync as expedited work if the app is able to. // If not, it runs as regular work. .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .setConstraints(SyncConstraints) // Delegate to the SyncWorker. .setInputData(SyncWorker::class.delegatedData()) .build() val SyncConstraints get() = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build()

    具體來說,SyncConstraints 定義的 Constraints 會規定 NetworkType 須為 NetworkType.CONNECTED。也就是說,系統會等到可使用網路後再執行。

    可使用網路後,worker 會委派適當的 Repository 例項,清空 SyncWorkName 指定的不重複工作佇列。如果同步處理作業失敗,doWork() 方法會傳回 Result.retry()。WorkManager 會以指數輪詢方式自動重試同步處理作業。否則,則會傳回 Result.success() 完成 以及同步處理功能

    class SyncWorker(...) : CoroutineWorker(appContext, workerParams), Synchronizer {
        override suspend fun doWork(): Result = withContext(ioDispatcher) {
            // First sync the repositories in parallel
            val syncedSuccessfully = awaitAll(
                async { topicRepository.sync() },
                async { authorsRepository.sync() },
                async { newsRepository.sync() },
            ).all { it }
            if (syncedSuccessfully) Result.success()
            else Result.retry()
    

    以下 Google 範例為離線優先應用程式。 歡迎查看這些範例,瞭解實務做法:

    [[["容易理解","easyToUnderstand","thumb-up"],["確實解決了我的問題","solvedMyProblem","thumb-up"],["其他","otherUp","thumb-up"]],[["缺少我需要的資訊","missingTheInformationINeed","thumb-down"],["過於複雜/步驟過多","tooComplicatedTooManySteps","thumb-down"],["過時","outOfDate","thumb-down"],["翻譯問題","translationIssue","thumb-down"],["示例/程式碼問題","samplesCodeIssue","thumb-down"],["其他","otherDown","thumb-down"]],["上次更新時間:2024-08-23 (世界標準時間)。"],[],[]]