離線優先應用程式是指無須存取網際網路,就能執行所有 (或部分關鍵) 核心功能的應用程式。也就是說,這類應用程式可以離線執行部分或所有商業邏輯。
建構離線優先應用程式時,首先要考慮的是用於存取應用程式資料和商業邏輯的資料層。應用程式可能需要不時從裝置外部來源重新整理這項資料。執行此操作時,應用程式可能需要呼叫網路資源來保持更新。
然而,我們無法保證隨時都能使用網路。裝置難免會遇到網路連線不穩或緩慢的問題。使用者也可能會遇到以下情況:
- 網際網路頻寬受限
- 連線暫時中斷,例如搭乘電梯或經過隧道時。
- 不定期存取資料。例如使用僅支援 Wi-Fi 上網的平板電腦。
不管原因為何,應用程式通常都能在上述情況下正常運作。為確保應用程式可在離線狀態下正確運作,應用程式應要符合以下條件:
- 即使沒有穩定的網路連線仍可使用。
- 會立即向使用者顯示本機資料,而不是靜靜等待第一個網路呼叫完成或失敗。
- 擷取資料的方式應考慮到電池和資料狀態。例如,僅在理想情況下 (例如充電或連上 Wi-Fi 時) 要求擷取資料。
符合上述條件的應用程式通常稱為離線優先應用程式。
設計離線優先應用程式
設計離線優先應用程式時,請先從資料層著手,然後再考量您可對應用程式資料執行的以下兩項主要操作:
- 讀取:擷取資料供應用程式其他部分使用,例如向使用者顯示資訊。
- 寫入:保留使用者輸入內容,以便日後擷取。
資料層中的存放區會負責合併資料來源,藉此提供應用程式資料。在離線優先應用程式中,至少要有一個資料來源不需要存取網路,就能執行最關鍵的工作,其中一個重要工作是讀取資料。
在離線優先應用程式中建立模型資料
對使用網路資源的各個存放區來說,離線優先應用程式至少有 2 個資料來源:
- 本機資料來源
- 網路資料來源
![離線優先資料層由本機和網路資料來源組成](https://developer.android.google.cn/static/images/topic/architecture/data-layer/data-layer.png?authuser=3&hl=zh-tw)
本機資料來源
本機資料來源是應用程式標準化的可靠資料來源。當應用程式中的較高層讀取任何資料時,都應將此做為專屬來源。這可確保資料在連線狀態之間保持一致。一般來說,本機資料來源是由保存在磁碟中的儲存空間負責備份。將資料保留至磁碟的一些常見方式如下:
- 結構化資料來源,例如 Room 等關聯資料庫。
- 非結構化資料來源。例如,帶有 Datastore 的通訊協定緩衝區。
- 簡易檔案
網路資料來源
網路資料來源是應用程式的實際狀態。本機資料來源最好能與網路資料來源同步,但也可以落後。在落後的情況下,應用程式必須在恢復連線時更新。相反地,在連線恢復且應用程式可以更新網路資料來源前,這項資料來源也可能落後於本機資料來源。應用程式的網域和 UI 層一律不應與網路層直接通訊,而是應由代管的 repository
負責通訊事宜,並將該網路層用於更新本機資料來源。
公開資源
應用程式在讀取及寫入本機和網路資料來源時,採取的方式存在根本差異。查詢本機資料來源既快速又有彈性,例如使用 SQL 查詢時便是如此。反之,網路資料來源可能較慢且受限,例如當透過 ID 以漸進方式存取符合 REST 樣式的資源時,就屬於這種情況。因此,每種資料來源往往都需要對自身提供的資料採用專屬的表示法。如此一來,本機資料來源和網路資料來源可能就有各自的模型。
下方目錄結構以視覺化方式呈現這個概念。AuthorEntity
代表從應用程式的本機資料庫讀取的作者,NetworkAuthor
則代表透過網路序列化的作者:
data/
├─ local/
│ ├─ entities/
│ │ ├─ AuthorEntity
│ ├─ dao/
│ ├─ NiADatabase
├─ network/
│ ├─ NiANetwork
│ ├─ models/
│ │ ├─ NetworkAuthor
├─ model/
│ ├─ Author
├─ repository/
下方則是 AuthorEntity
和 NetworkAuthor
的詳細資料:
/**
* 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,
)
建議您同時將 AuthorEntity
和 NetworkAuthor
保留在資料層內,並公開第三種類型供外部層使用。假如本機和網路資料來源中的細微變更並未徹底改變應用程式行為,上述做法可以確保外部層不受這些變更影響。詳情請參閱以下程式碼片段:
/**
* 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()) }
}
網路資料來源
如果從網路資料來源讀取資料時發生錯誤,應用程式需要透過經驗法則重新試著擷取資料。常見的經驗法則包括:
指數輪詢
在指數輪詢中,應用程式會不斷嘗試從網路資料來源讀取資料,兩次嘗試間的時間間隔也會持續增加,直到讀取成功或其他條件指示其應該停止讀取為止。
![以指數輪詢方式讀取資料](https://developer.android.google.cn/static/images/topic/architecture/data-layer/read-backoff.png?authuser=3&hl=zh-tw)
評估應用程式是否應持續輪詢的標準包括:
- 網路資料來源指出的錯誤類型。例如,如果網路呼叫傳回的錯誤指出連線中斷,則應重試網路呼叫。反之,在提供適當憑證之前,請勿重試未授權的 HTTP 要求。
- 重試次數上限。
網路連線監控
在這個方法中,讀取要求會排入佇列,直到應用程式確定可以連線至網路資料來源為止。建立連線後,系統會將讀取要求移出佇列,然後讀取資料並更新本機資料來源。在 Android 上,這個佇列可能會以 Room 資料庫進行維護,並使用 WorkManager 做為永久性作業來清空。
![使用網路監控器和佇列讀取資料](https://developer.android.google.cn/static/images/topic/architecture/data-layer/read-queue.png?authuser=3&hl=zh-tw)
寫入
雖然我們建議在離線優先應用程式中讀取資料時使用可觀察的類型,但寫入 API 等同於暫停函式等非同步 API。這可避免封鎖 UI 執行緒,並協助處理錯誤,因為在跨越網路邊界的期間,離線優先應用程式中的寫入作業可能會失敗。
interface UserDataRepository {
/**
* Updates the bookmarked status for a news resource
*/
suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
}
在上方的程式碼片段中,由於上述方法暫停,選擇的非同步 API 為協同程式。
寫入策略
在離線優先應用程式中寫入資料時,請考慮採用以下三種策略。具體要選擇哪種策略,取決於寫入的資料類型和應用程式的要求:
僅限線上寫入
嘗試橫跨網路邊界寫入資料。如果成功,請更新本機資料來源,否則請擲回例外狀況,並交由呼叫端妥善回覆。
![僅限線上寫入](https://developer.android.google.cn/static/images/topic/architecture/data-layer/write-online-only.png?authuser=3&hl=zh-tw)
這項策略通常用於必須近乎即時地在線上執行的寫入交易,例如銀行轉帳。由於寫入可能失敗,因此往往必須告知使用者無法寫入,或禁止使用者嘗試一開始就寫入資料。在這些情況下,您可以使用的策略可能包括:
- 如果應用程式需要具備網際網路存取權才能寫入資料,可能會選擇不向使用者顯示方便他們寫入資料的 UI,或者至少停用這項功能。
- 您可以採用使用者無法關閉的彈出式視窗訊息,或透過臨時提示,通知使用者他們目前處於離線狀態。
已加入佇列的寫入
如果您有想要寫入的物件,請將其插入佇列中。接著在應用程式恢復連線後,繼續以指數輪詢的方式清空佇列。在 Android 上,清空離線佇列是永久性作業,通常會委派給 WorkManager
。
![透過重試寫入佇列](https://developer.android.google.cn/static/images/topic/architecture/data-layer/write-backoff.png?authuser=3&hl=zh-tw)
此方法適用於以下情況:
- 並非一定要將資料寫入網路。
- 交易不具時效性。
- 作業失敗時,並非一定要告知使用者。
此方法的用途包括分析事件和記錄。
延遲寫入
請先寫入本機資料來源,然後將寫入作業排入佇列,以便盡快通知網路。這一點非常重要,因為當應用程式恢復網路連線後,網路和本機資料來源之間可能會發生衝突。下一節將詳細介紹如何解決衝突。
![透過網路監控功能延遲寫入](https://developer.android.google.cn/static/images/topic/architecture/data-layer/write-queue.png?authuser=3&hl=zh-tw)
如果資料對應用程式而言非常重要,此方法就是不二之選。舉例來說,在待辦事項清單的離線優先應用程式中,使用者離線新增的所有工作都必須儲存在本機,以避免資料遺失的風險。
同步處理及衝突解決
離線優先的應用程式恢復連線時,需要核對本機與網路資料來源中的資料。這項程序稱為同步處理。應用程式與網路資料來源同步處理的方法主要有兩種:
- 提取式同步處理
- 推送式同步處理
提取式同步處理
在提取式同步處理作業中,應用程式會連上網路,按需求讀取最新的應用程式資料。這個方法的常見經驗法則是以導覽為基礎,採用此做法時,應用程式只會在向使用者顯示資料前擷取資料。
如果應用程式預計在短期到中期內都沒有網路連線,這個方法就非常實用。這是因為重新整理資料需要見機行事,在長期沒有連線的情況下,使用者就越有可能利用過時或空白的快取,嘗試造訪應用程式目的地。
![提取式同步處理](https://developer.android.google.cn/static/images/topic/architecture/data-layer/pull-sync.png?authuser=3&hl=zh-tw)
假設在某個應用程式中,網頁權杖的用途是針對特定畫面擷取無盡捲動清單內的項目。這個實作方法可能會延遲連上網路、將資料保存至本機資料來源,然後從本機資料來源讀取資料,以便將資訊傳回給使用者。在沒有網路連線的情況下,存放區可能只會從本機資料來源要求資料。這是 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 快取。 |
系統一律不會擷取不需要的資料。 | 不妥善使用關聯資料調整規模,因為提取的模型本身必須夠高。如果正在同步處理的模型仰賴其他待擷取的模型來填入內容,前述大量使用資料的問題將會更加嚴重。此外,這也可能導致父項模型與巢狀模型的存放區彼此依賴。 |
推送式同步處理
在推送式同步處理作業中,本機資料來源會盡可能嘗試模仿網路資料來源的備用資源組合。第一次啟動時,它會主動擷取適量資料來設定基準,之後便會運用來自伺服器的通知,在資料過時時發出快訊。
![推送式同步處理](https://developer.android.google.cn/static/images/topic/architecture/data-layer/push-sync.png?authuser=3&hl=zh-tw)
收到過時通知後,應用程式會連線至網路,只更新標示為過時的資料。這項工作會委派給 Repository
,由其負責連線至網路資料來源,並保留擷取到本機資料來源的資料。由於存放區會公開具有可觀測類型的內部資料,因此讀取工具會收到所有變更通知。
class UserDataRepository(...) {
suspend fun synchronize() {
val userData = networkDataSource.fetchUserData()
localDataSource.saveUserData(userData)
}
}
在這個方法中,應用程式大幅降低對網路資料來源的依賴程度,而且可在長時間沒有這類資料來源的情況下運作。這在離線時一併提供了讀取及寫入存取權,因為系統會假設本機存有來自網路資料來源的最新資訊。
下表匯總了推送式同步處理的優點和缺點:
優點 | 缺點 |
---|---|
應用程式可無限期離線使用。 | 針對衝突解決方案管理資料版本並不容易。 |
可盡可能減少使用資料。應用程式只會擷取已變更的資料。 | 需要考慮同步期間的寫入問題。 |
適用於關聯資料。每個存放區都只負責為自己支援的模型擷取資料。 | 網路資料來源需要支援同步處理作業。 |
混合式同步處理
部分應用程式會採用混合方式,根據資料選擇提取式或推送式做法。舉例來說,由於動態消息的更新頻率較高,社群媒體應用程式可能會使用提取式同步處理功能,按需求擷取使用者追蹤的動態消息。同一應用程式可以選擇使用推送式同步處理功能,取得已登入使用者的相關資料,包括使用者名稱、個人資料相片等。
歸根究底來說,離線優先的同步處理選項,須視產品需求和可用的技術基礎架構而定。
衝突解決
如果應用程式在離線期間寫入本機的資料並非來自網路資料來源,就表示發生衝突,而您必須解決衝突才能執行同步處理作業。
如要解決衝突,通常需要藉助版本管理。應用程式會需要執行一些簿記工作來記錄發生變更的時間,進而將中繼資料傳遞給網路資料來源。接著,網路資料來源會負責提供絕對可靠的資料來源。您可以根據應用程式的需求,考慮採取多種策略來解決衝突問題。對行動應用程式而言,常見的做法是「以最後寫入者為準」。
以最後寫入者為準
在這個方法中,裝置會在寫入網路的資料中附加時間戳記中繼資料。當網路資料來源收到這些資料時,會捨棄比目前狀態舊的所有資料,同時接受比目前狀態新的資料。
![「以最後寫入者為準」衝突解決方案](https://developer.android.google.cn/static/images/topic/architecture/data-layer/last-write-wins.png?authuser=3&hl=zh-tw)
在上圖中,兩部裝置均處於離線狀態,且最初皆與網路資料來源保持同步。離線時,兩者都會在本機寫入資料,並記錄寫入資料的時間。如果這兩者皆再次連上網路並與網路資料來源同步,網路會保留裝置 B 中的資料,藉此解決衝突問題,因為該裝置寫入資料的時間較晚。
在離線優先應用程式中使用 WorkManager
在上文介紹的讀取和寫入策略中,有兩種常見的公用程式:
- 佇列
- 讀取:用於將讀取作業延遲到有網路連線為止。
- 寫入:用於將寫入作業延遲到有網路連線為止,並重新排入佇列以便重試。
- 網路連線監控器
- 讀取:當做信號使用,以便在應用程式連線時清空讀取佇列,也用於同步處理
- 寫入:當做信號使用,以便在應用程式連線時清空寫入佇列,也用於同步處理
這兩個情況都是說明 WorkManager 擅長處理永久性作業的範例。例如在 Now in Android 範例應用程式中,當您同步處理本機資料來源時,WorkManager 可做為讀取佇列和網路監控器使用。啟動時,該應用程式會執行下列操作:
- 將讀取同步處理工作排入佇列,確保本機與網路資料來源保持一致。
- 清空讀取同步處理佇列,並在應用程式連線時開始同步。
- 使用指數輪詢功能從網路資料來源執行讀取作業。
- 將讀取的結果保留在本機資料來源中,解決任何可能出現的衝突。
- 公開來自本機資料來源的資料,供應用程式的其他層使用。
上述過程如下圖所示:
![Now in Android 應用程式中的資料同步作業](https://developer.android.google.cn/static/images/topic/architecture/data-layer/nia-sync.png?authuser=3&hl=zh-tw)
透過 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 範例為離線優先應用程式。 歡迎查看這些範例,瞭解實務做法: