離線優先(offline-first)應用程式是一種即使在斷線狀態下,仍能提供大部分或全部功能的應用程式。離線優先應用程式通常依賴已儲存的資料,讓使用者能暫時存取原本僅能線上取得的資料。

有些離線優先應用程式能無縫結合本地與遠端資料,而有些應用程式則會在使用快取資料時通知使用者。同樣地,有些應用程式會在背景自動同步資料,而有些則需要使用者明確執行同步。這一切取決於應用程式的需求與所提供的功能,由開發者決定哪種實作方式最符合需求。

在本指南中,你將學習如何在 Flutter 中,依循Flutter 架構指引,實作不同的離線優先應用程式方案。

離線優先架構

#

如常見架構概念指南所述,repository(儲存庫)扮演單一真實資料來源(single source of truth)的角色。它們負責呈現本地或遠端資料,並且應該是唯一能修改資料的地方。在離線優先應用程式中,repository 會結合不同的本地與遠端資料來源,無論裝置的連線狀態如何,都能在單一存取點提供資料。

本範例使用 UserProfileRepository,這是一個支援離線優先的 repository,可用來取得與儲存 UserProfile 物件。

UserProfileRepository 使用了兩種不同的資料服務:一種處理遠端資料,另一種則操作本地資料庫。

API client ApiClientService 會透過 HTTP REST 呼叫連接至遠端服務。

dart
class ApiClientService {
  /// performs GET network request to obtain a UserProfile
  Future<UserProfile> getUserProfile() async {
    // ···
  }

  /// performs PUT network request to update a UserProfile
  Future<void> putUserProfile(UserProfile userProfile) async {
    // ···
  }
}

資料庫服務 DatabaseService 使用 SQL 來儲存資料,
這與 Persistent Storage Architecture: SQL 教學中的實作方式類似。

dart
class DatabaseService {
  /// Fetches the UserProfile from the database.
  /// Returns null if the user profile is not found.
  Future<UserProfile?> fetchUserProfile() async {
    // ···
  }

  /// Update UserProfile in the database.
  Future<void> updateUserProfile(UserProfile userProfile) async {
    // ···
  }
}

本範例同樣使用了 UserProfile 資料類別(data class),該類別是透過 freezed 套件建立的。

dart
@freezed
abstract class UserProfile with _$UserProfile {
  const factory UserProfile({
    required String name,
    required String photoUrl,
  }) = _UserProfile;
}

在具有複雜資料的應用程式中, 例如當遠端資料包含的欄位比 UI 所需的還要多時, 你可能會希望為 API 和資料庫服務使用一個資料類別, 而為 UI 使用另一個資料類別。 舉例來說, 資料庫實體可以使用 UserProfileLocal, API 回應物件可以使用 UserProfileRemote, 而 UI 資料模型類別則可以使用 UserProfileUserProfileRepository 則負責在需要時將資料進行相互轉換。

這個範例同時也包含了 UserProfileViewModel, 這是一個 view model(檢視模型),它會使用 UserProfileRepository 來在元件(Widget)上顯示 UserProfile

dart
class UserProfileViewModel extends ChangeNotifier {
  // ···
  final UserProfileRepository _userProfileRepository;

  UserProfile? get userProfile => _userProfile;
  // ···

  /// Load the user profile from the database or the network
  Future<void> load() async {
    // ···
  }

  /// Save the user profile with the new name
  Future<void> save(String newName) async {
    // ···
  }
}

讀取資料

#

讀取資料是任何依賴遠端 API 服務的應用程式中,最基本的部分之一。

在 Offline-first(離線優先)應用程式中, 你希望確保存取這些資料的速度盡可能快, 並且不會因為裝置是否連線而影響到向使用者提供資料。 這與 Optimistic State 設計模式 類似。

在本節中, 你將學習兩種不同的方法: 一種是將資料庫作為備援, 另一種則是使用 Stream 結合本地與遠端資料。

使用本地資料作為備援

#

作為第一種方法, 你可以透過備援機制來實作離線支援, 以因應使用者離線或網路請求失敗的情況。

在這種情境下,UserProfileRepository 會嘗試透過 ApiClientService 從遠端 API 伺服器取得 UserProfile。 如果這個請求失敗, 則會從 DatabaseService 返回本地儲存的 UserProfile

dart
Future<UserProfile> getUserProfile() async {
  try {
    // Fetch the user profile from the API
    final apiUserProfile = await _apiClientService.getUserProfile();
    //Update the database with the API result
    await _databaseService.updateUserProfile(apiUserProfile);

    return apiUserProfile;
  } catch (e) {
    // If the network call failed,
    // fetch the user profile from the database
    final databaseUserProfile = await _databaseService.fetchUserProfile();

    // If the user profile was never fetched from the API
    // it will be null, so throw an  error
    if (databaseUserProfile != null) {
      return databaseUserProfile;
    } else {
      // Handle the error
      throw Exception('User profile not found');
    }
  }
}

使用 Stream

#

一個更好的替代方案是使用Stream來呈現資料。
在最佳情況下,
Stream會發出兩個值,
分別是本地儲存的資料以及來自伺服器的資料。

首先,stream 會透過DatabaseService發出本地儲存的資料。
這個呼叫通常比網路請求更快且較不容易出錯,
而且先執行這個步驟可以讓 view model 已經能夠顯示資料給使用者。

如果資料庫中沒有任何快取資料,
那麼Stream就會完全依賴網路請求,
只會發出一個值。

接著,該方法會使用ApiClientService進行網路請求,
以取得最新的資料。
如果請求成功,
它會用新取得的資料更新資料庫,
然後再將該值傳遞給 view model,
以便顯示給使用者。

dart
Stream<UserProfile> getUserProfile() async* {
  // Fetch the user profile from the database
  final userProfile = await _databaseService.fetchUserProfile();
  // Returns the database result if it exists
  if (userProfile != null) {
    yield userProfile;
  }

  // Fetch the user profile from the API
  try {
    final apiUserProfile = await _apiClientService.getUserProfile();
    //Update the database with the API result
    await _databaseService.updateUserProfile(apiUserProfile);
    // Return the API result
    yield apiUserProfile;
  } catch (e) {
    // Handle the error
  }
}

view model 必須訂閱此 Stream,並等待其完成。
為此,請以 Subscription 物件呼叫 asFuture(),並等待結果。

對於每一個取得的值,
請更新 view model 的資料,並呼叫 notifyListeners()
以便 UI 顯示最新資料。

dart
Future<void> load() async {
  await _userProfileRepository
      .getUserProfile()
      .listen(
        (userProfile) {
          _userProfile = userProfile;
          notifyListeners();
        },
        onError: (error) {
          // handle error
        },
      )
      .asFuture<void>();
}

僅使用本機資料

#

另一種可行的方法是針對讀取操作僅使用本機儲存的資料。
這種做法要求資料必須在某個時刻預先載入至資料庫,
並且需要有一套同步機制來確保資料能夠隨時保持最新狀態。

dart
Future<UserProfile> getUserProfile() async {
  // Fetch the user profile from the database
  final userProfile = await _databaseService.fetchUserProfile();

  // Return the database result if it exists
  if (userProfile == null) {
    throw Exception('Data not found');
  }

  return userProfile;
}

Future<void> sync() async {
  try {
    // Fetch the user profile from the API
    final userProfile = await _apiClientService.getUserProfile();

    // Update the database with the API result
    await _databaseService.updateUserProfile(userProfile);
  } catch (e) {
    // Try again later
  }
}

這種方法適用於不需要隨時與伺服器同步資料的應用程式。 例如,一個天氣應用程式,其天氣資料每天只更新一次。

同步可以由使用者手動執行,例如下拉重新整理(pull-to-refresh)動作,進而呼叫 sync() 方法, 或是由 Timer 或背景程序定期執行。 你可以在「同步狀態」的章節中學習如何實作同步任務。

寫入資料

#

在 Offline-first(離線優先)應用程式中,寫入資料的方式根本上取決於應用程式的使用情境。

有些應用程式可能需要使用者輸入的資料能夠立即在伺服器端取得, 而其他應用程式則可能較為彈性,允許資料暫時不同步。

本節將說明兩種在 Offline-first 應用程式中實作資料寫入的不同方法。

僅限線上寫入(Online-only writing)

#

在 Offline-first 應用程式中,一種資料寫入的方法是強制必須在線上狀態下才能寫入資料。 雖然這聽起來有些違反直覺,但這可以確保使用者所修改的資料 能夠完全與伺服器同步,應用程式的狀態不會與伺服器不同步。

在這種情況下,你會先嘗試將資料傳送到 API 服務, 如果請求成功,再將資料儲存到資料庫中。

dart
Future<void> updateUserProfile(UserProfile userProfile) async {
  try {
    // Update the API with the user profile
    await _apiClientService.putUserProfile(userProfile);

    // Only if the API call was successful
    // update the database with the user profile
    await _databaseService.updateUserProfile(userProfile);
  } catch (e) {
    // Handle the error
  }
}

在這種情況下的缺點是,離線優先(offline-first)功能僅適用於讀取(read)操作, 但不適用於寫入(write)操作,因為寫入操作需要使用者處於線上狀態。

離線優先寫入(Offline-first writing)

#

第二種做法則相反。 應用程式不是先執行網路呼叫, 而是先將新資料儲存到資料庫中, 然後在本地儲存完成後,再嘗試將其傳送到 API 服務。

dart
Future<void> updateUserProfile(UserProfile userProfile) async {
  // Update the database with the user profile
  await _databaseService.updateUserProfile(userProfile);

  try {
    // Update the API with the user profile
    await _apiClientService.putUserProfile(userProfile);
  } catch (e) {
    // Handle the error
  }
}

這種做法允許使用者即使在應用程式離線時,也能將資料儲存在本地端。然而,若網路請求失敗,本地資料庫與 API 服務將不再同步。在下一節中,你將學習如何處理本地與遠端資料同步的不同方法。

同步狀態

#

保持本地與遠端資料同步是 Offline-first 應用程式中非常重要的一環,因為本地所做的變更需要複製到遠端服務。應用程式也必須確保,當使用者回到應用程式時,本地儲存的資料與遠端服務中的資料一致。

撰寫同步任務

#

有多種方法可以在背景任務中實作同步機制。

一個簡單的解決方案是在UserProfileRepository中建立Timer,讓它定期執行,例如每五分鐘一次。

dart
Timer.periodic(const Duration(minutes: 5), (timer) => sync());

sync() 方法接著會從資料庫擷取 UserProfile
如果需要同步,則會將其傳送至 API 服務。

dart
Future<void> sync() async {
  try {
    // Fetch the user profile from the database
    final userProfile = await _databaseService.fetchUserProfile();

    // Check if the user profile requires synchronization
    if (userProfile == null || userProfile.synchronized) {
      return;
    }

    // Update the API with the user profile
    await _apiClientService.putUserProfile(userProfile);

    // Set the user profile as synchronized
    await _databaseService.updateUserProfile(
      userProfile.copyWith(synchronized: true),
    );
  } catch (e) {
    // Try again later
  }
}

一個更複雜的解決方案是使用像 workmanager 這樣的背景處理插件。這允許你的應用程式即使在未執行時,也能在背景中執行同步處理程序。

同時建議僅在網路可用時執行同步任務。例如,你可以使用 connectivity_plus 插件來檢查裝置是否已連接至 WiFi。你也可以利用 battery_plus 來確認裝置電量是否充足。

在前面的範例中,同步任務每 5 分鐘執行一次。在某些情況下,這可能過於頻繁,而在其他情境下則可能不夠頻繁。實際的同步週期時間需依你的應用程式需求而定,這部分需要你自行決定。

儲存同步旗標(synchronization flag)

#

為了判斷資料是否需要同步,可以在資料類別中新增一個旗標(flag),用來指示該變更是否需要同步。

例如,bool synchronized

dart
@freezed
abstract class UserProfile with _$UserProfile {
  const factory UserProfile({
    required String name,
    required String photoUrl,
    @Default(false) bool synchronized,
  }) = _UserProfile;
}

你的同步邏輯應該僅在synchronized旗標為false時,才嘗試將資料傳送到 API 服務。 如果請求成功,則將其狀態改為true

從伺服器推送資料

#

另一種同步方式是使用推播服務(push service)將最新資料提供給應用程式。 在這種情況下,是伺服器在資料變更時主動通知應用程式, 而不是由應用程式主動查詢更新。

例如,你可以使用 Firebase messaging, 將小型資料負載推送到裝置, 同時也能透過背景訊息(background messages)遠端觸發同步任務。

與其讓同步任務在背景持續執行, 不如讓伺服器在儲存的資料需要更新時, 透過推播通知(push notification)主動通知應用程式。

你也可以將這兩種方式結合, 同時執行背景同步任務並利用背景推播訊息, 以確保應用程式資料庫與伺服器保持同步。

綜合應用

#

撰寫一個離線優先(offline-first)應用程式 需要針對讀取、寫入與同步操作的實作方式做出決策, 這些決策會依你正在開發的應用程式需求而有所不同。

重點整理如下:

  • 讀取資料時, 你可以使用Stream,將本地儲存資料與遠端資料結合。
  • 寫入資料時, 請決定是否需要在線上或離線狀態下執行, 以及是否需要稍後再同步資料。
  • 實作背景同步任務時, 請考量裝置狀態與應用程式需求, 因為不同應用程式可能有不同的需求。