跳至主要內容

離線優先支援

離線優先應用程式是指在未連線到網際網路時,仍能提供大部分或全部功能的應用程式。離線優先應用程式通常依賴儲存的資料,讓使用者可以臨時存取原本只能線上取得的資料。

有些離線優先應用程式會無縫結合本地和遠端資料,而其他應用程式則會在應用程式使用快取資料時通知使用者。同樣地,有些應用程式會在背景同步資料,而其他應用程式則要求使用者明確同步。這一切都取決於應用程式的需求及其提供的功能,並且由開發人員決定哪種實作方式適合他們的需求。

在本指南中,您將學習如何在 Flutter 中實作不同的離線優先應用程式方法,並遵循 Flutter 架構指南

離線優先架構

#

正如通用架構概念指南中所述,儲存庫(repositories)是單一的事實來源。它們負責呈現本地或遠端資料,並且應該是唯一可以修改資料的地方。在離線優先應用程式中,儲存庫結合不同的本地和遠端資料來源,以單一存取點呈現資料,而與裝置的連線狀態無關。

這個範例使用 UserProfileRepository,這是一個儲存庫,可讓您取得和儲存具有離線優先支援的 UserProfile 物件。

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

API 用戶端 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 儲存資料,類似於 持久儲存架構: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 {
    // ···
  }
}

此範例也使用已使用 freezed 套件建立的 UserProfile 資料類別。

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

在具有複雜資料的應用程式中,例如當遠端資料包含比 UI 需要的更多欄位時,您可能會希望 API 和資料庫服務有一個資料類別,而 UI 有另一個資料類別。例如,資料庫實體使用 UserProfileLocal,API 回應物件使用 UserProfileRemote,然後 UI 資料模型類別使用 UserProfileUserProfileRepository 會在必要時負責從一個轉換到另一個。

此範例還包括 UserProfileViewModel,這是一個檢視模型,使用 UserProfileRepository 在小工具上顯示 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 服務的應用程式的基本部分。

在離線優先應用程式中,您希望確保對此資料的存取速度越快越好,並且它不依賴裝置是否連線來向使用者提供資料。這類似於樂觀狀態設計模式

在本節中,您將學習兩種不同的方法,一種是使用資料庫作為後備,另一種是使用 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 會發出兩個值:本地儲存的資料和伺服器的資料。

首先,串流使用 DatabaseService 發出本地儲存的資料。此呼叫通常比網路呼叫更快且更不容易出錯,並且通過先執行此操作,檢視模型已經可以向使用者顯示資料。

如果資料庫不包含任何快取資料,則 Stream 完全依賴網路呼叫,只發出一個值。

然後,該方法使用 ApiClientService 執行網路呼叫以取得最新資料。如果請求成功,它會使用新取得的資料更新資料庫,然後將值傳遞給檢視模型,以便顯示給使用者。

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
  }
}

檢視模型必須訂閱此 Stream 並等待其完成。為此,請使用 Subscription 物件呼叫 asFuture() 並等待結果。

對於每個取得的值,更新檢視模型資料並呼叫 notifyListeners(),以便 UI 顯示最新資料。

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

僅使用本機資料

#

另一種可能的方法是使用本地儲存的資料進行讀取操作。這種方法要求資料在某個時間點已預先載入到資料庫中,並且需要一個可以保持資料最新的同步機制。

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
  }
}

此方法對於不需要資料始終與伺服器同步的應用程式很有用。例如,天氣應用程式,天氣資料每天只更新一次。

同步可以由使用者手動完成,例如,一個下拉更新動作,然後呼叫 sync() 方法,或者由 Timer 或背景程序定期完成。您可以在關於同步狀態的部分學習如何實作同步任務。

寫入資料

#

在離線優先應用程式中寫入資料基本上取決於應用程式的使用案例。

有些應用程式可能要求使用者輸入的資料立即在伺服器端可用,而其他應用程式可能更靈活,允許資料暫時不同步。

本節說明在離線優先應用程式中實作寫入資料的兩種不同方法。

僅限線上寫入

#

在離線優先應用程式中寫入資料的一種方法是強制連線才能寫入資料。雖然這聽起來可能有違直覺,但這確保使用者已修改的資料與伺服器完全同步,並且應用程式的狀態與伺服器沒有不同。

在這種情況下,您首先嘗試將資料傳送到 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
  }
}

這種情況下的缺點是離線優先功能僅適用於讀取操作,而不適用於寫入操作,因為寫入操作要求使用者連線。

離線優先寫入

#

第二種方法的工作方式相反。應用程式不是先執行網路呼叫,而是先將新資料儲存在資料庫中,然後一旦在本地儲存後,就嘗試將其傳送到 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 服務將不再同步。在下一節中,您將學習處理本地和遠端資料之間同步的不同方法。

同步狀態

#

保持本地和遠端資料同步是離線優先應用程式的重要部分,因為已在本地完成的變更需要複製到遠端服務。應用程式還必須確保,當使用者返回應用程式時,本地儲存的資料與遠端服務中的資料相同。

撰寫同步工作

#

有不同的方法可以在背景工作中實作同步。

一個簡單的解決方案是在 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 外掛程式來檢查裝置是否連線到 Wi-Fi。您也可以使用 battery_plus 來驗證裝置是否電量不足。

在前面的範例中,同步任務每 5 分鐘執行一次。在某些情況下,這可能過多,而在其他情況下,它可能不夠頻繁。應用程式的實際同步週期時間取決於您的應用程式需求,這是您必須決定的。

儲存同步旗標

#

要了解資料是否需要同步,請在資料類別中新增一個旗標,指出是否需要同步變更。

例如,bool synchronized

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

您的同步邏輯應僅在 synchronized 旗標為 false 時嘗試將其傳送到 API 服務。如果請求成功,則將其變更為 true

從伺服器推送資料

#

同步的另一種方法是使用推送服務向應用程式提供最新資料。在這種情況下,伺服器會在資料變更時通知應用程式,而不是應用程式要求更新。

例如,您可以使用 Firebase 訊息,將少量資料有效負載推送至裝置,並使用背景訊息遠端觸發同步任務。

伺服器不是讓同步任務在背景中執行,而是在需要使用推送通知更新儲存的資料時通知應用程式。

您可以將兩種方法結合在一起,使用背景同步任務和使用背景推送訊息,以使應用程式資料庫與伺服器同步。

整合在一起

#

編寫離線優先應用程式需要針對讀取、寫入和同步操作的實作方式做出決策,這取決於您正在開發的應用程式的需求。

主要的重點是

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