離線優先應用程式是指在斷開網際網路連線的情況下,仍能提供大部分或全部功能的應用程式。離線優先應用程式通常依賴於儲存的資料,以便使用者能夠臨時訪問原本只能在線獲取的資料。

一些離線優先應用程式可以無縫地組合本地和遠端資料,而另一些應用程式則會在應用程式使用快取資料時通知使用者。同樣,一些應用程式會在後臺同步資料,而另一些則需要使用者顯式同步。這完全取決於應用程式的需求和它提供的功能,由開發人員決定哪種實現最適合他們的需求。

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

離線優先架構

#

正如在通用架構概念指南中所解釋的,儲存庫充當事實的唯一來源。它們負責呈現本地或遠端資料,並且應該是唯一可以修改資料的地方。在離線優先應用程式中,儲存庫組合不同的本地和遠端資料來源,以便在裝置的連線狀態無關的情況下,在一個訪問點呈現資料。

本示例使用 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
abstract class UserProfile with _$UserProfile {
  const factory UserProfile({
    required String name,
    required String photoUrl,
  }) = _UserProfile;
}

在具有複雜資料的應用程式中,例如遠端資料包含比 UI 所需的更多欄位時,您可能希望為 API 和資料庫服務使用一個數據類,為 UI 使用另一個數據類。例如,UserProfileLocal 用於資料庫實體,UserProfileRemote 用於 API 響應物件,然後 UserProfile 用於 UI 資料模型類。UserProfileRepository 將負責在必要時在它們之間進行轉換。

本示例還包括 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 執行網路呼叫以獲取最新資料。如果請求成功,它會用新獲取的資料更新資料庫,然後將該值yield 給檢視模型,以便顯示給使用者。

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

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

同步可以由使用者手動完成,例如,透過下拉重新整理操作呼叫 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,該 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 分鐘執行一次。在某些情況下,這可能太多了,而在另一些情況下,這可能不夠頻繁。您的應用程式的實際同步週期取決於您的應用程式需求,您需要自己決定。

儲存同步標誌

#

要了解資料是否需要同步,請向資料類新增一個標誌,指示更改是否需要同步。

例如,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

從伺服器推送資料

#

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

例如,您可以使用 Firebase messaging,推送小型資料負載到裝置,並通過後臺訊息觸發遠端同步任務。

不是讓同步任務在後臺執行,而是伺服器透過推送通知通知應用程式何時需要更新儲存的資料。

您可以將這兩種方法結合起來,使用後臺同步任務和後臺推送訊息,以保持應用程式資料庫與伺服器同步。

整合所有概念

#

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

關鍵要點是

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