주요 콘텐츠로 건너뛰기

오프라인 영속성 (실험적)

오프라인 영속성은 Provider의 상태를 사용자 기기에 저장해, 사용자가 오프라인이거나 앱을 다시 시작한 뒤에도 그 상태에 접근할 수 있게 하는 기능입니다.

Riverpod은 데이터를 저장하는 데이터베이스나 프로토콜에 종속되지 않습니다. 다만 기본적으로 기본 JSON 직렬화와 함께 riverpod_sqflite를 제공합니다.

주의

Riverpod의 오프라인 영속성은 데이터베이스를 감싸는 간단한 래퍼로 설계되었습니다. 데이터베이스와 상호작용하는 코드를 완전히 대체하기 위한 것이 아닙니다.

다음과 같은 경우에는 여전히 데이터베이스를 직접 다뤄야 할 수 있습니다.

  • 고급 데이터베이스 마이그레이션
  • 더 최적화된 저장 전략
  • 일반적이지 않은 사용 사례

오프라인 영속성은 두 부분으로 이루어집니다.

  1. Storage: 데이터베이스와 상호작용하기 위한 인터페이스입니다. 보통 패키지(예: riverpod_sqflite)가 구현을 제공합니다.
  2. AnyNotifier.persist: Notifier 안에서 영속성을 활성화할 때 사용하는 함수입니다.

Storage 생성하기​

Notifier를 영속화하기 전에 먼저 Storage 인터페이스를 구현한 객체를 생성해야 합니다. 이 객체가 Riverpod과 데이터베이스를 연결하는 역할을 합니다.

다음 중 하나를 선택해야 합니다.

  • 원하는 데이터베이스와 Riverpod을 연결해 주는 패키지를 설치합니다.
  • Storage를 직접 구현합니다.

SQFlite를 사용한다면 riverpod_sqflite를 사용할 수 있습니다.

dart pub add riverpod_sqflite sqflite

그런 다음 JsonSqFliteStorage의 인스턴스를 만들어 Storage를 생성합니다.

import 'package:flutter_riverpod/experimental/persist.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart';
import 'package:riverpod_sqflite/riverpod_sqflite.dart';
import 'package:sqflite/sqflite.dart';

final storageProvider = FutureProvider<Storage<String, String>>((ref) async {
// Initialize SQFlite. We should share the Storage instance between providers.
return JsonSqFliteStorage.open(
join(await getDatabasesPath(), 'riverpod.db'),
);
});

provider의 상태 영속화하기​

Storage를 생성했다면 이제 provider의 상태를 영속화할 수 있습니다.
현재는 "Notifier"만 영속화할 수 있습니다. 자세한 내용은 Provider를 참고하세요.

Notifier의 상태를 영속화하려면 보통 Notifier의 build 메서드 안에서 AnyNotifier.persist를 호출합니다.

class Todo {
Todo({required this.task});
final String task;
}

final todoListProvider = AsyncNotifierProvider<TodoList, List<Todo>>(
TodoList.new,
);

class TodoList extends AsyncNotifier<List<Todo>> {

Future<List<Todo>> build() async {
persist(
// We pass in the previously created Storage.
// Do not "await" this. Riverpod will handle it for you.
ref.watch(storageProvider.future),
// A unique identifier for this state.
// If your provider receives parameters, make sure to encode those
// in the key as well.
key: 'todo_list',
// Encode/decode the state. Here, we're using a basic JSON encoding.
// You can use any encoding you want, as long as your Storage supports it.
encode: (todos) => todos.map((todo) => {'task': todo.task}).toList(),
decode:
(json) =>
(json as List)
.map((todo) => Todo(task: todo['task'] as String))
.toList(),
);

// Regardless of whether some state was restored or not, we fetch the list of
// todos from the server.
return fetchTodosFromServer();
}
}

간소화된 JSON 직렬화 사용하기 (코드 생성)​

riverpod_sqflite와 코드 생성을 함께 사용한다면 JsonPersist 어노테이션으로 persist 호출을 간소화할 수 있습니다.

// Using freezed or json_serializable to generate from/toJson for your objects

abstract class Todo with _$Todo {
const factory Todo({required String task}) = _Todo;

factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}


// Specify @JsonPersist. This will provide a custom "persist" method for your notifier
()
class TodoList extends _$TodoList {

Future<List<Todo>> build() async {
persist(
// We pass in the previously created Storage.
// Do not "await" this. Riverpod will handle it for you.
ref.watch(storageProvider.future),
// No need to specify key/encode/decode functions.
);

// Initialize the notifier as usual.
return fetchTodosFromServer();
}
}

persist 키 이해하기​

앞선 일부 코드에서 AnyNotifier.persist에 key 매개변수를 넘겼습니다. 이 키는 데이터베이스가 provider의 상태를 어디에 저장할지 알 수 있게 해 줍니다. 데이터베이스에 따라 이 키는 고유한 행 ID가 될 수 있습니다.

key를 지정할 때는 다음 사항을 반드시 지켜야 합니다.

  • 영속화하는 모든 provider 사이에서 키가 고유해야 합니다.
    그렇지 않으면 두 provider가 데이터베이스의 같은 행에 쓰려고 하면서 데이터가 손상될 수 있습니다. Riverpod은 같은 키를 사용하는 provider 두 개를 감지하면 assertion 에러를 발생시킵니다.
  • 앱을 다시 시작해도 키가 바뀌지 않아야 합니다. 키가 바뀌면 앱을 다시 시작했을 때 Riverpod이 provider의 상태를 복원할 수 없으며, provider는 한 번도 영속화된 적이 없는 것처럼 초기화됩니다.
  • provider가 받는 모든 매개변수가 키에 포함되어야 합니다. "family"(Family 참고)를 사용할 때는 family 매개변수를 키에 포함해야 합니다.

캐시 기간 변경하기​

기본적으로 상태는 2일 동안만 캐시됩니다. 이 기본값 덕분에 누수가 생기지 않고, 삭제된 provider가 데이터베이스에 무기한 남아 있지 않습니다.

Riverpod은 주로 IO 작업(네트워크 요청, 데이터베이스 쿼리 등)의 캐시로 사용하도록 설계되었기 때문에 이 기본값은 대체로 안전합니다. 하지만 사용자 설정을 저장하는 경우처럼 모든 사용 사례에 적합하지는 않습니다.

기본값을 바꾸려면 다음과 같이 options를 지정합니다.


Future<List<Todo>> build() async {
persist(
ref.watch(storageProvider.future),
// We tell Riverpod to forever persist the state of this provider.
options: const StorageOptions(
// Instead of "unsafe_forever", you can alternatively specify a Duration.
cacheTime: StorageCacheTime.unsafe_forever,
),
// ...
);

return fetchTodosFromServer();
}
주의

캐시 기간을 무제한으로 설정했다면, 나중에 provider를 삭제할 때 영속화된 상태도 데이터베이스에서 직접 삭제해야 합니다.

방법은 사용하는 데이터베이스의 문서를 참고하세요.

"destroy key"로 간단한 데이터 마이그레이션하기​

데이터를 영속화할 때 흔히 겪는 문제는 데이터 구조가 바뀌는 경우입니다. 객체의 직렬화 방식을 바꾸면 데이터베이스에 저장된 데이터를 마이그레이션해야 할 수 있습니다.

Riverpod이 본격적인 데이터 마이그레이션 방법을 제공하지는 않지만, 기존에 영속화된 상태를 완전히 새로운 상태로 쉽게 교체하는 방법은 제공합니다. 바로 destroy key입니다.

class TodoList extends AsyncNotifier<List<Todo>> {

Future<List<Todo>> build() async {
persist(
ref.watch(storageProvider.future),
// We can optionally pass a "destroyKey". When a new version of the application
// is release with a different destroyKey, the old persisted state will be
// deleted, and a brand new state will be created.
options: const StorageOptions(destroyKey: '1.0'),
// Persist as usual
key: 'todo_list',
encode: (todos) => todos.map((todo) => {'task': todo.task}).toList(),
decode:
(json) =>
(json as List)
.map((todo) => Todo(task: todo['task'] as String))
.toList(),
);

return fetchTodosFromServer();
}
}

destroy key를 사용하면 Riverpod이 기존 영속화 상태를 언제 버려야 하는지 알 수 있어 간단한 데이터 마이그레이션에 도움이 됩니다. 다른 destroyKey로 애플리케이션의 새 버전을 배포하면 기존 영속화 상태는 버려지고, provider는 한 번도 영속화된 적이 없는 것처럼 초기화됩니다.

영속화 데이터 디코딩 기다리기​

지금까지는 AnyNotifier.persist가 완료되기를 기다리지 않았습니다.
이는 의도된 것으로, provider가 네트워크 요청을 최대한 빨리 시작할 수 있게 하기 위함입니다. 하지만 그 대신 persist를 호출한 직후에는 provider가 영속화된 상태에 쉽게 접근할 수 없습니다.

경우에 따라서는 네트워크 요청 대신 영속화된 상태로 provider를 초기화하고 싶을 수 있습니다.

이럴 때는 다음과 같이 persist의 결과를 await하면 됩니다.

await persist(...).future;

이렇게 하면 build 안에서 this.state로 영속화된 상태에 접근할 수 있습니다.


Future<List<Todo>> build() async {
// Wait for decoding to complete
await persist(
ref.watch(storageProvider.future),
// ...
).future;

// If any state has been decoded, initialize the provider with it.
// Otherwise provide a default value.
return state.value ?? <Todo>[];
}

영속성 테스트하기​

애플리케이션을 테스트할 때 실제 데이터베이스를 사용하는 것은 번거로울 수 있습니다. 특히 단위 테스트와 위젯 테스트는 기기에 접근할 수 없으므로 데이터베이스를 사용할 수 없습니다.

그래서 Riverpod은 Storage.inMemory로 인메모리 데이터베이스를 사용하는 방법을 제공합니다.
테스트에서 이 인메모리 데이터베이스를 사용하려면 Provider 오버라이드를 활용하면 됩니다.

testWidgets('Widget test example', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// Override the `storageProvider` so that our application
// uses an in-memory storage.
storageProvider.overrideWith((ref) {
// Create an in-memory storage.
final storage = Storage<String, String>.inMemory();
// Initialize it with some data.
storage.write(
'todo_list',
'{"task": "Eat a cookie"}',
const StorageOptions(),
);

return storage;
}),
],
child: const MyApp(),
),
);
});

test('Pure dart example', () {
final container = ProviderContainer.test(
// Same as above, we override the `storageProvider`
overrides: [
storageProvider.overrideWith(
(ref) => Storage<String, String>.inMemory(),
),
],
);

// TODO use container to interact with providers by hand.
});