建構離線優先應用程式

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

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

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

  • 網際網路頻寬受限
  • 連線暫時中斷,例如搭乘電梯或經過隧道時。
  • 不定期存取資料。例如使用僅支援 Wi-Fi 上網的平板電腦。

不管原因為何,應用程式通常都能在上述情況下正常運作。為確保應用程式可在離線狀態下正確運作,應用程式應要符合以下條件:

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

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

設計離線優先應用程式

設計離線優先應用程式時,請先從資料層著手,然後再考量您可對應用程式資料執行的以下兩項主要操作:

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

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

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

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

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

本機資料來源

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

  • 結構化資料來源,例如 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 要求。
  • 重試次數上限。
網路連線監控

在這個方法中,讀取要求會排入佇列,直到應用程式確定可以連線至網路資料來源為止。建立連線後,系統會將讀取要求移出佇列,然後讀取資料並更新本機資料來源。在 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 可做為讀取佇列和網路監控器使用。啟動時,該應用程式會執行下列操作:

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

上述過程如下圖所示:

Now in 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 會規定 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 範例為離線優先應用程式。 歡迎查看這些範例,瞭解實務做法: