Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile): Cache assets and albums for faster loading speed #826

Merged
merged 8 commits into from
Oct 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions mobile/lib/modules/album/providers/album.provider.dart
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:openapi/api.dart';

class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
AlbumNotifier(this._albumService) : super([]);
AlbumNotifier(this._albumService, this._albumCacheService) : super([]);
final AlbumService _albumService;
final AlbumCacheService _albumCacheService;

_cacheState() {
_albumCacheService.put(state);
}

getAllAlbums() async {

if (await _albumCacheService.isValid() && state.isEmpty) {
state = await _albumCacheService.get();
}

List<AlbumResponseDto>? albums =
await _albumService.getAlbums(isShared: false);

if (albums != null) {
state = albums;
_cacheState();
}
}

deleteAlbum(String albumId) {
state = state.where((album) => album.id != albumId).toList();
_cacheState();
}

Future<AlbumResponseDto?> createAlbum(
Expand All @@ -28,6 +41,8 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {

if (album != null) {
state = [...state, album];
_cacheState();

return album;
}
return null;
Expand All @@ -36,5 +51,8 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {

final albumProvider =
StateNotifierProvider<AlbumNotifier, List<AlbumResponseDto>>((ref) {
return AlbumNotifier(ref.watch(albumServiceProvider));
return AlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(albumCacheServiceProvider),
);
});
21 changes: 19 additions & 2 deletions mobile/lib/modules/album/providers/shared_album.provider.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:openapi/api.dart';

class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
SharedAlbumNotifier(this._sharedAlbumService) : super([]);
SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService) : super([]);

final AlbumService _sharedAlbumService;
final SharedAlbumCacheService _sharedAlbumCacheService;

_cacheState() {
_sharedAlbumCacheService.put(state);
}

Future<AlbumResponseDto?> createSharedAlbum(
String albumName,
Expand All @@ -22,6 +28,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {

if (newAlbum != null) {
state = [...state, newAlbum];
_cacheState();
}

return newAlbum;
Expand All @@ -33,23 +40,30 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
}

getAllSharedAlbums() async {
if (await _sharedAlbumCacheService.isValid() && state.isEmpty) {
state = await _sharedAlbumCacheService.get();
}

List<AlbumResponseDto>? sharedAlbums =
await _sharedAlbumService.getAlbums(isShared: true);

if (sharedAlbums != null) {
state = sharedAlbums;
_cacheState();
}
}

deleteAlbum(String albumId) async {
state = state.where((album) => album.id != albumId).toList();
_cacheState();
}

Future<bool> leaveAlbum(String albumId) async {
var res = await _sharedAlbumService.leaveAlbum(albumId);

if (res) {
state = state.where((album) => album.id != albumId).toList();
_cacheState();
return true;
} else {
return false;
Expand All @@ -72,7 +86,10 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {

final sharedAlbumProvider =
StateNotifierProvider<SharedAlbumNotifier, List<AlbumResponseDto>>((ref) {
return SharedAlbumNotifier(ref.watch(albumServiceProvider));
return SharedAlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider),
);
});

final sharedAlbumDetailProvider = FutureProvider.autoDispose
Expand Down
49 changes: 49 additions & 0 deletions mobile/lib/modules/album/services/album_cache.service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@

import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
import 'package:openapi/api.dart';

class BaseAlbumCacheService extends JsonCache<List<AlbumResponseDto>> {
BaseAlbumCacheService(super.cacheFileName);

@override
void put(List<AlbumResponseDto> data) {
putRawData(data.map((e) => e.toJson()).toList());
}

@override
Future<List<AlbumResponseDto>> get() async {
try {
final mapList = await readRawData() as List<dynamic>;

final responseData = mapList
.map((e) => AlbumResponseDto.fromJson(e))
.whereNotNull()
.toList();

return responseData;
} catch (e) {
debugPrint(e.toString());
return [];
}
}
}

class AlbumCacheService extends BaseAlbumCacheService {
AlbumCacheService() : super("album_cache");
}

class SharedAlbumCacheService extends BaseAlbumCacheService {
SharedAlbumCacheService() : super("shared_album_cache");
}

final albumCacheServiceProvider = Provider(
(ref) => AlbumCacheService(),
);

final sharedAlbumCacheServiceProvider = Provider(
(ref) => SharedAlbumCacheService(),
);

37 changes: 37 additions & 0 deletions mobile/lib/modules/home/services/asset_cache.service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
import 'package:openapi/api.dart';


class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
AssetCacheService() : super("asset_cache");

@override
void put(List<AssetResponseDto> data) {
putRawData(data.map((e) => e.toJson()).toList());
}

@override
Future<List<AssetResponseDto>> get() async {
try {
final mapList = await readRawData() as List<dynamic>;

final responseData = mapList
.map((e) => AssetResponseDto.fromJson(e))
.whereNotNull()
.toList();

return responseData;
} catch (e) {
debugPrint(e.toString());

return [];
}
}
}

final assetCacheServiceProvider = Provider(
(ref) => AssetCacheService(),
);
15 changes: 14 additions & 1 deletion mobile/lib/modules/login/providers/authentication.provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
Expand All @@ -15,6 +17,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
this._deviceInfoService,
this._backupService,
this._apiService,
this._assetCacheService,
this._albumCacheService,
this._sharedAlbumCacheService,
) : super(
AuthenticationState(
deviceId: "",
Expand All @@ -41,6 +46,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
final DeviceInfoService _deviceInfoService;
final BackupService _backupService;
final ApiService _apiService;
final AssetCacheService _assetCacheService;
final AlbumCacheService _albumCacheService;
final SharedAlbumCacheService _sharedAlbumCacheService;

Future<bool> login(
String email,
Expand Down Expand Up @@ -151,7 +159,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Future<bool> logout() async {
Hive.box(userInfoBox).delete(accessTokenKey);
state = state.copyWith(isAuthenticated: false);

_assetCacheService.invalidate();
_albumCacheService.invalidate();
_sharedAlbumCacheService.invalidate();
return true;
}

Expand Down Expand Up @@ -197,5 +207,8 @@ final authenticationProvider =
ref.watch(deviceInfoServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(apiServiceProvider),
ref.watch(assetCacheServiceProvider),
ref.watch(albumCacheServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider),
);
});
34 changes: 32 additions & 2 deletions mobile/lib/shared/providers/asset.provider.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
Expand All @@ -9,24 +10,50 @@ import 'package:photo_manager/photo_manager.dart';

class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
final AssetService _assetService;
final AssetCacheService _assetCacheService;

final DeviceInfoService _deviceInfoService = DeviceInfoService();

AssetNotifier(this._assetService) : super([]);
AssetNotifier(this._assetService, this._assetCacheService) : super([]);

_cacheState() {
_assetCacheService.put(state);
}

getAllAsset() async {
final stopwatch = Stopwatch();


if (await _assetCacheService.isValid() && state.isEmpty) {
stopwatch.start();
state = await _assetCacheService.get();
debugPrint("Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
}
Comment on lines +27 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my understanding of this code block, the data will be displayed from the cache first, then will fetch the new data in the background and update the state, correct?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the state is updated twice. First time from the cache, second time from API.
I think one possible optimization would be to start both background jobs first and then await the results. But that would cause a race condition so I avoided it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, would it worth to create an api endpoint to get the asset count for each user and check that count before perform the fetch from the server?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO not really. Checking the cache costs as few ms, loading data from the cache 200-300ms.


stopwatch.start();
var allAssets = await _assetService.getAllAsset();
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();

if (allAssets != null) {
state = allAssets;

stopwatch.start();
_cacheState();
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
}
}

clearAllAsset() {
state = [];
_cacheState();
}

onNewAssetUploaded(AssetResponseDto newAsset) {
state = [...state, newAsset];
_cacheState();
}

deleteAssets(Set<AssetResponseDto> deleteAssets) async {
Expand Down Expand Up @@ -65,12 +92,15 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
state.where((immichAsset) => immichAsset.id != asset.id).toList();
}
}

_cacheState();
}
}

final assetProvider =
StateNotifierProvider<AssetNotifier, List<AssetResponseDto>>((ref) {
return AssetNotifier(ref.watch(assetServiceProvider));
return AssetNotifier(
ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider));
});

final assetGroupByDateTimeProvider = StateProvider((ref) {
Expand Down
49 changes: 49 additions & 0 deletions mobile/lib/shared/services/json_cache.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import 'dart:convert';
import 'dart:io';

import 'package:path_provider/path_provider.dart';

abstract class JsonCache<T> {
final String cacheFileName;

JsonCache(this.cacheFileName);

Future<File> _getCacheFile() async {
final basePath = await getTemporaryDirectory();
final basePathName = basePath.path;

final file = File("$basePathName/$cacheFileName.bin");

return file;
}

Future<bool> isValid() async {
final file = await _getCacheFile();
return await file.exists();
}

Future<void> invalidate() async {
final file = await _getCacheFile();
await file.delete();
}

Future<void> putRawData(dynamic data) async {
final jsonString = json.encode(data);
final file = await _getCacheFile();

if (!await file.exists()) {
await file.create();
}

await file.writeAsString(jsonString);
}

dynamic readRawData() async {
final file = await _getCacheFile();
final data = await file.readAsString();
return json.decode(data);
}

void put(T data);
Future<T> get();
}