建構離線優先應用程式

透過集合功能整理內容 你可以依據偏好儲存及分類內容。

離線優先應用程式是指在沒有存取網際網路的情況下,才能執行其核心功能的所有或關鍵子集的應用程式。也就是說,它可以離線執行部分或所有商業邏輯。

建構離線優先應用程式時,首先要考慮資料層提供了應用程式資料及商業邏輯的存取。應用程式可能需要不時重新整理裝置外部來源的資料。這項操作可能需要呼叫網路資源才能隨時掌握最新資訊。

不保證網路可用性。裝置通常有停滯或網路連線緩慢的現象。使用者可能會遇到以下情況:

  • 有限網際網路頻寬
  • 暫時性連線中斷,例如搭乘電梯或通道。
  • 不定期存取資料。例如僅限 Wi-Fi 平板電腦。

無論原因為何,應用程式在各種情況下通常都能正常運作。為確保應用程式能正確離線運作,應能夠執行以下操作:

  • 即使沒有穩定的網路連線,仍可正常運作。
  • 立即向使用者顯示本機資料,而不是等待第一個網路呼叫完成或失敗。
  • 擷取資料的方式應考慮到電池和資料狀態。例如,僅在最佳條件 (例如充電或連上 Wi-Fi) 時要求資料擷取。

符合上述條件的應用程式通常稱為離線優先應用程式。

設計離線優先應用程式

設計離線優先應用程式時,請先從資料層開始,然後對應用程式資料執行兩項主要作業:

  • 讀取:擷取資料供應用程式其他部分使用,例如向使用者顯示資訊。
  • 寫入:保留使用者輸入內容,以便日後擷取。

資料層中的存放區負責合併資料來源以提供應用程式資料。在離線優先應用程式中,至少要有一個資料來源不需要網路存取權,才能執行最重要的工作。其中一項重要工作是讀取資料。

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

離線優先應用程式對使用網路資源的每個存放區有至少 2 個資料來源:

  • 本機資料來源
  • 網路資料來源
離線優先資料層包含本機和網路資料來源
圖 1:離線優先存放區

本機資料來源

本機資料來源是應用程式的標準化真實來源。其應是應用程式更高層讀取的任何資料專屬來源。如此可確保連線狀態之間的資料一致性。本機資料來源通常是由保存在磁碟中的儲存空間備份。將資料保留至磁碟的一些常見方式如下:

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

網路資料來源

網路資料來源是應用程式的實際狀態。本機資料來源正處於與網路資料來源的最佳同步。也可以落後,在這種情況下,必須在恢復連線時更新應用程式。相反地,網路資料來源可能會延遲到本機資料來源後方,直到應用程式在連線恢復時更新資料來源。應用程式的網域和 UI 層不得與網路層直接通訊。託管「repository」負責與之通訊,並將其用於更新本機資料來源。

公開資源

本機和網路資料來源在應用程式讀取及寫入的方式上有根本不同。查詢本機資料來源既快速又有彈性,例如使用 SQL 查詢時。反之,網路資料來源的速度可能較慢,並受到限制,例如,當 ID 以漸進方式存取 RESTful 資源時。因此,每種資料來源通常都需要自己提供的資料作為自我表示法。因此,本機資料來源和網路資料來源可能會有自己的模型。

下方的目錄結構會將這個概念視覺化。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 要求。
  • 重試次數上限。
網路連線監控

在這個方法中,讀取要求會排入佇列,直到應用程式確定可以連線至網路資料來源為止。建立連線後,系統會將讀取要求排入佇列,然後讀取資料並更新本機資料來源。在 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 為 Coroutines

寫入策略

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

僅限線上寫入

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

僅限線上寫入
圖 4:僅限線上寫入

這項策略通常用於必須近乎即時地在線上發生的寫入交易。例如銀行轉帳。由於寫入可能失敗,因此往往必須告知使用者寫入失敗,或禁止使用者嘗試一開始就寫入資料。在這些情況下,您可以使用以下一些策略:

  • 如果應用程式需要具備網際網路存取權來寫入資料,可能會選擇不向使用者顯示使用者介面,以便使用者寫入資料,或至少停用資料。
  • 您可以使用使用者無法關閉的彈出式視窗訊息,或使用臨時提示,通知使用者他們目前離線。

已加入佇列的寫入

如果有想要寫入的物件,請將其插入佇列中。當應用程式恢復連線後,繼續以指數輪詢的方式清空佇列。在 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 同步處理本機資料來源時,可做為讀取佇列和網路監控之用。啟動時,應用程式會執行下列動作:

  1. 將讀取同步處理工作排入佇列,確保本機資料來源與網路資料來源之間保持一致。
  2. 清空讀取同步處理佇列,並在應用程式連線時開始同步。
  3. 使用指數輪詢功能從網路資料來源執行讀取作業。
  4. 將讀取的結果保留在本機資料來源,以解決任何可能出現的衝突。
  5. 顯示來自本機資料來源的資料,供應用程式的其他層使用。

如下圖所示:

Android 版應用程式中的「即時資訊」中的資料同步處理
圖 10: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 需要 NetworkTypeNetworkType.CONNECTED。也就是說,系統會等到網路可用後再執行。

網路可用後,工作站會委派適當的 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 範例為離線優先應用程式。 請查看這些範例,以便瞭解實際做法: