├── .gitignore ├── example ├── pubspec.yaml └── example.dart ├── lib ├── flutter_cache_store.dart └── src │ ├── policies │ ├── fifo_policy.dart │ ├── less_recently_used_policy.dart │ ├── cache_control_policy.dart │ ├── timestamp_based_policy.dart │ └── least_frequently_used_policy.dart │ ├── cache_store_policy.dart │ ├── utils.dart │ └── cache_store.dart ├── pubspec.yaml ├── flutter_cache_store.iml ├── LICENSE ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .flutter-plugins* 3 | .metadata 4 | .packages 5 | .pub/ 6 | .dart_tool/ 7 | android/ 8 | ios/ 9 | pubspec.lock 10 | -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flutter_cache_store_example 2 | homepage: https://github.com/eGust/flutter_cache_store 3 | 4 | environment: 5 | sdk: '>=2.10.0 <3.0.0' 6 | 7 | dependencies: 8 | flutter: 9 | sdk: flutter 10 | flutter_cache_store: 11 | path: ../ 12 | -------------------------------------------------------------------------------- /lib/flutter_cache_store.dart: -------------------------------------------------------------------------------- 1 | library flutter_cache_store; 2 | 3 | export 'src/cache_store.dart'; 4 | export 'src/cache_store_policy.dart'; 5 | 6 | export 'src/policies/cache_control_policy.dart'; 7 | export 'src/policies/fifo_policy.dart'; 8 | export 'src/policies/least_frequently_used_policy.dart'; 9 | export 'src/policies/less_recently_used_policy.dart'; 10 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flutter_cache_store 2 | description: More configurable cache manager for Flutter. Cache http get 3 | requests to mobile devices file system. 4 | version: 0.8.1 5 | homepage: https://github.com/eGust/flutter_cache_store 6 | 7 | environment: 8 | sdk: ">=2.10.0 <3.0.0" 9 | 10 | dependencies: 11 | flutter: 12 | sdk: flutter 13 | http: ^0.13.0 14 | path_provider: ^1.1.0 15 | shared_preferences: ^0.5.0 16 | synchronized: ^2.1.0 17 | -------------------------------------------------------------------------------- /lib/src/policies/fifo_policy.dart: -------------------------------------------------------------------------------- 1 | import 'timestamp_based_policy.dart'; 2 | 3 | /// Implements a FIFO (first in, first out) Policy. 4 | /// This policy is pretty useless. Mainly it's for demo purpose. 5 | class FifoPolicy extends TimestampBasedPolicy { 6 | /// When reach [maxCount], oldest files will be clean first. 7 | FifoPolicy({int maxCount = 999}) : super(maxCount); 8 | 9 | static const _KEY = 'CACHE_STORE:FIFO'; 10 | String get storeKey => _KEY; 11 | 12 | Future onAdded(final CacheItem item) async { 13 | item.payload ??= TimestampPayload(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/src/policies/less_recently_used_policy.dart: -------------------------------------------------------------------------------- 1 | import 'timestamp_based_policy.dart'; 2 | 3 | /// Implements a Less-Recently-Used Policy. 4 | /// This is the default policy if you dont specify a policy in [CacheStore.setPolicy]. 5 | class LessRecentlyUsedPolicy extends TimestampBasedPolicy { 6 | /// When reach [maxCount], LRU file will be deleted first. 7 | LessRecentlyUsedPolicy({int maxCount = 999}) : super(maxCount); 8 | 9 | static const _KEY = 'CACHE_STORE:LRU'; 10 | String get storeKey => _KEY; 11 | 12 | Future onAccessed(final CacheItem item, bool) async { 13 | if (item.payload == null) 14 | item.payload = TimestampPayload(); 15 | else 16 | updateTimestamp(item); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /flutter_cache_store.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, James 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /lib/src/policies/cache_control_policy.dart: -------------------------------------------------------------------------------- 1 | import 'timestamp_based_policy.dart'; 2 | 3 | /// Implements a very basic Policy of HTTP `Cache-Control` 4 | class CacheControlPolicy extends TimestampBasedPolicy { 5 | /// When cached files reach [maxCount], files with least age to live will be removed first. 6 | /// Be careful of [minAge]. Your app may still need it for a while. 7 | /// Both [minAge] and [maxAge] will override `max-age` or `s-maxage` header. 8 | /// Set [maxAge] to `null` to make it follow `max-age` or `s-maxage` rule. 9 | CacheControlPolicy({ 10 | int maxCount = 999, 11 | Duration minAge = const Duration(seconds: 30), 12 | Duration maxAge = const Duration(days: 30), 13 | }) : this.minAge = minAge?.inSeconds, 14 | this.maxAge = maxAge?.inSeconds, 15 | super(maxCount); 16 | 17 | final int maxAge; 18 | final int minAge; 19 | static const _KEY = 'CACHE_STORE:HTML'; 20 | String get storeKey => _KEY; 21 | 22 | int now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; 23 | 24 | Future onDownloaded( 25 | final CacheItem item, final Map headers) async { 26 | final cc = (headers['cache-control'] ?? '') 27 | .split(',') 28 | .map((s) => s.trim()) 29 | .map((s) => s.startsWith('max-age=') || s.startsWith('s-maxage=') 30 | ? s.split('=')[1] 31 | : null) 32 | .where((s) => s != null); 33 | 34 | var age = cc.isEmpty ? 0 : int.tryParse(cc.first) ?? 0; 35 | if (minAge != null && age < minAge) age = minAge; 36 | if (maxAge != null && age > maxAge) age = maxAge; 37 | 38 | item.payload = TimestampPayload(now() + age); 39 | } 40 | 41 | Future> cleanup(Iterable allItems) async { 42 | final ts = now(); 43 | final expired = []; 44 | final list = allItems.where((item) { 45 | if ((getTimestamp(item) ?? 0) > ts) return true; 46 | expired.add(item); 47 | return false; 48 | }).toList(); 49 | 50 | if (maxCount == null || list.length <= maxCount) { 51 | saveItems(list); 52 | return expired; 53 | } 54 | 55 | list.sort((a, b) => (getTimestamp(a) ?? 0) - (getTimestamp(b) ?? 0)); 56 | expired.addAll(list.sublist(maxCount)); 57 | 58 | saveItems(list.sublist(0, maxCount)); 59 | return expired; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/src/policies/timestamp_based_policy.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import '../cache_store.dart'; 4 | import '../cache_store_policy.dart'; 5 | 6 | export 'dart:async'; 7 | export '../cache_store.dart'; 8 | 9 | /// [CacheItemPayload] to hold a timestamp field 10 | class TimestampPayload extends CacheItemPayload { 11 | TimestampPayload([int value]) { 12 | timestamp = value ?? DateTime.now().millisecondsSinceEpoch; 13 | } 14 | int timestamp; 15 | } 16 | 17 | /// Generic base class for policies based on timestamps. 18 | /// This is a good example of how to implement a policy. 19 | /// You can override this class if you need a timestamp-based policy. 20 | abstract class TimestampBasedPolicy extends CacheStorePolicy { 21 | static const MAX_COUNT = 100 * 1000; // 100k 22 | TimestampBasedPolicy(this.maxCount) { 23 | if (maxCount <= 0 || maxCount >= MAX_COUNT) 24 | throw RangeError.range(maxCount, 0, MAX_COUNT, 'maxCount'); 25 | } 26 | 27 | String get storeKey; // must override 28 | 29 | int getTimestamp(CacheItem item) => 30 | (item?.payload as TimestampPayload)?.timestamp; 31 | 32 | void updateTimestamp(CacheItem item, [int value]) => 33 | (item.payload as TimestampPayload)?.timestamp = 34 | value ?? DateTime.now().millisecondsSinceEpoch; 35 | 36 | final int maxCount; 37 | 38 | Future saveItems(List items) async { 39 | final timestamps = {}; 40 | items.forEach((item) { 41 | final ts = getTimestamp(item) ?? DateTime.now().millisecondsSinceEpoch; 42 | timestamps[item.key] = ts.toString(); 43 | }); 44 | 45 | await CacheStore.prefs.setString(storeKey, jsonEncode(timestamps)); 46 | } 47 | 48 | Future> cleanup(Iterable allItems) async { 49 | final list = allItems.toList(); 50 | if (list.length <= maxCount) { 51 | saveItems(list); 52 | return []; 53 | } 54 | 55 | list.sort((a, b) => (getTimestamp(a) ?? 0) - (getTimestamp(b) ?? 0)); 56 | 57 | saveItems(list.sublist(0, maxCount)); 58 | return list.sublist(maxCount); 59 | } 60 | 61 | Future> restore(List allItems) async { 62 | Map stored = 63 | jsonDecode(CacheStore.prefs.getString(storeKey) ?? '{}'); 64 | final now = DateTime.now().millisecondsSinceEpoch; 65 | return allItems.map((item) { 66 | final String ts = stored[item.key]; 67 | item.payload = TimestampPayload(ts == null ? now : int.parse(ts)); 68 | return item; 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /lib/src/cache_store_policy.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'cache_store.dart'; 3 | import 'utils.dart'; 4 | 5 | export 'dart:async'; 6 | 7 | typedef OnDownloaded = Future Function(CacheItem, Map); 8 | 9 | /// Base class of a `Policy`. 10 | /// [cleanup] is the only method you must override. 11 | /// You still need override at least one of [onAdded], [onAccessed] and [onDownloaded]. 12 | /// You may need to override [restore] as well. 13 | /// 14 | /// You can follow the code of [TimestampBasedPolicy] and [TimestampPayload]. 15 | abstract class CacheStorePolicy { 16 | /// MUST override this method. 17 | /// You need to evict items based on the strategies. 18 | /// [allItems] includes all living [CacheItem] records holt by [CacheStore]. 19 | /// You may not need it if you implemented your own data structure. 20 | /// Return all EXPIRED items. [CacheStore] will manage to remove them from disk. 21 | /// You may need to save persistent data to disk as well. 22 | Future> cleanup(Iterable allItems); 23 | 24 | /// Restores persisted data when initializing. 25 | /// [allItems] is a list of items [CacheStore] restored from disk. 26 | /// Return all VALID items that still should be cached. Other files will be removed soon. 27 | Future> restore(List allItems) async => 28 | allItems; 29 | 30 | /// Event that triggers after an item has been added. 31 | /// [FifoPolicy] is a sample that only overrides this method. 32 | Future onAdded(final CacheItem addedItem) async {} 33 | 34 | /// Event that triggers when the file has been visited. 35 | /// Both [LessRecentlyUsedPolicy] and [LeastFrequentlyUsedPolicy] are based on this event. 36 | Future onAccessed(final CacheItem accessedItem, bool flushed) async {} 37 | 38 | /// Event that triggers after when http request is finished. 39 | /// [CacheControlPolicy] is mainly based on this event. 40 | Future onDownloaded( 41 | final CacheItem item, final Map headers) async {} 42 | 43 | /// Triggers after [CacheItem.flush] to clear your data. 44 | Future onFlushed(final Iterable flushedItems) async {} 45 | 46 | /// Override this when you need do extra work to clear your data on disk 47 | Future clearAll(Iterable allItems) async {} 48 | 49 | /// Override this to customize the filename with relative path on disk. 50 | /// There is a good example - `Cache File Structure` in `README.md` 51 | String generateFilename({final String key, final String url}) => 52 | Utils.genName(); 53 | } 54 | -------------------------------------------------------------------------------- /example/example.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:flutter_cache_store/flutter_cache_store.dart'; 3 | import 'package:http/http.dart' show Response, get, post; 4 | 5 | // [GET STARTED] 6 | void demo(String url) async { 7 | final store = await CacheStore.getInstance(); 8 | final file = await store.getFile(url); 9 | // do something with file... 10 | } 11 | 12 | // [BASIC OPTIONS] 13 | void api() async { 14 | // get store instance 15 | CacheStore store = await CacheStore.getInstance( 16 | namespace: 17 | 'unique_name', // default: null - valid filename used as unique id 18 | policy: 19 | LeastFrequentlyUsedPolicy(), // default: null - will use `LessRecentlyUsedPolicy()` 20 | clearNow: true, // default: false - whether to clean up immediately 21 | fetch: myFetch, // default: null - a shortcut of `CacheStore.fetch` 22 | ); 23 | 24 | // You can change custom fetch method at anytime. 25 | // Set it to `null` will simply use `http.get` 26 | store.fetch = myFetch; 27 | 28 | // fetch a file from an URL and cache it 29 | File file = await store.getFile( 30 | 'url', // GET method 31 | key: null, // use custom string instead of URL 32 | headers: {}, // same as http.get 33 | fetch: myFetch, // Optional: CustomFunction for making custom request 34 | // Optional: Map any custom you want to pass to your custom fetch function. 35 | custom: {'method': 'POST', 'body': 'test'}, 36 | flushCache: false, // whether to re-download the file 37 | ); 38 | 39 | // flush specific files by keys 40 | await store.flush([ 41 | 'key', // key (default is the URL) passed to `getFile` 42 | ]); 43 | 44 | // remove all cached files 45 | await store.clearAll(); 46 | } 47 | 48 | // Custom fetch function. 49 | // A demo of how you can achieve a fetch supporting POST with body 50 | Future myFetch(url, 51 | {Map headers, Map custom}) { 52 | final data = custom ?? {}; 53 | switch (data['method'] ?? '') { 54 | case 'POST': 55 | { 56 | return post(url, headers: headers, body: data['body']); 57 | } 58 | default: 59 | return get(url, headers: headers); 60 | } 61 | } 62 | 63 | // [ADVANCED USAGE] 64 | // Extends a Policy class and override `generateFilename` 65 | class LRUCachePolicy extends LessRecentlyUsedPolicy { 66 | LRUCachePolicy({int maxCount}) : super(maxCount: maxCount); 67 | 68 | @override 69 | String generateFilename({final String key, final String url}) => 70 | key; // use key as the filename 71 | } 72 | 73 | void customizedCacheFileStructure() async { 74 | // get store instance 75 | CacheStore store = await CacheStore.getInstance( 76 | policy: LRUCachePolicy(maxCount: 4096), 77 | namespace: 'my_store', 78 | ); 79 | 80 | // fetch a file from an URL and cache it 81 | String bookId = 'book123'; 82 | String chapterId = 'ch42'; 83 | String chapterUrl = 'https://example.com/book123/ch42'; 84 | File file = await store.getFile( 85 | chapterUrl, 86 | key: '$bookId/$chapterId', // use IDs as path and filename 87 | ); 88 | 89 | // Your file will be cached as `$TEMP/cache_store__my_store/book123/ch42` 90 | } 91 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [0.8.1] - Released on 2020-01-11 4 | 5 | - Updated `http.get` interface 6 | 7 | ## [0.7.2] - Released on 2020-01-11 8 | 9 | - Rewrote cleanup logic 10 | 11 | ## [0.7.1] - Released on 2019-12-25 12 | 13 | - Fixed serialization issue 14 | 15 | ## [0.7.0] - Released on 2019-10-19 16 | 17 | - Added `namespace` to support multiple instances 18 | - Breaking Changes: 19 | - Removed static `CacheStore.setPolicy` method 20 | - Moved `CacheStore.fetch` to instance field 21 | - Renamed `CacheStore.getInstance` parameter `httpGetter` to `fetch` 22 | 23 | ### Upgrade Guide 24 | 25 | 1. remove `CacheStore.setPolicy` and set the policy in `CacheStore.getInstance`. For example, `CacheStore.getInstance(policy: LessRecentlyUsedPolicy(maxCount: 4096))` 26 | 2. if `httpGetter: myFetch` is used in `CacheStore.getInstance`, rename it to `fetch: myFetch` 27 | 3. any `CacheStore.fetch = myFetch` should be `store.fetch = myFetch` 28 | 29 | For instance, old API: 30 | 31 | ```diff 32 | void foo() async { 33 | - CacheStore.setPolicy(LessRecentlyUsedPolicy(maxCount: 4096)); // 1 34 | CacheStore store = await CacheStore.getInstance( 35 | + policy: LessRecentlyUsedPolicy(maxCount: 4096), // 1 36 | clearNow: true, 37 | - httpGetter: bar, // 2 38 | + fetch: bar, // 2 39 | ); 40 | 41 | - CacheStore.fetch = baz; // 3 42 | + store.fetch = baz; // 3 43 | } 44 | ``` 45 | 46 | ## [0.6.0] - Released on 2019-07-08 47 | 48 | - Fixed a bug `CacheStore.fetch` not working. 49 | - Added optional `CustomFetch fetch` parameter to `getFile`. 50 | 51 | ## [0.5.0] - Released on 2019-06-19 52 | 53 | - Nothing but dependencies upgrade 54 | 55 | ## [0.4.0] - Released on 2019-02-06 56 | 57 | - Added `CustomFetch` support: 58 | - Now you can use custom function to fetch data instead of `http.get`. 59 | - Added named optional parameter `Map custom` to `getFile`, so you can pass custom data to your custom fetch function. 60 | 61 | ## [0.3.2+2] - Released on 2019-01-20 62 | 63 | - Fixed stupid Health suggestions. 64 | 65 | ## [0.3.2] - Released on 2019-01-15 66 | 67 | - First official release. Nothing changed. 68 | 69 | ## [0.3.1-RC2] - Released on 2018-12-08 70 | 71 | - Fixed deprecated `int.parse`. 72 | 73 | ## [0.3.0-RC2] - Released on 2018-12-08 74 | 75 | - Added inline documents. 76 | - `LessRecentlyUsedPolicy` has been tested for a while and worked pretty well. 77 | 78 | ## [0.3.0-RC1] - Updated on 2018-11-24 79 | 80 | - Added `LeastFrequentlyUsedPolicy`, `CacheControlPolicy` and `FifoPolicy`. 81 | - File structure changes. 82 | - Checking `maxCount` parameter now. 83 | 84 | ## [0.2.0-beta2] - Released on 2018-11-10 85 | 86 | ### Breaking Changes 87 | 88 | - Changed interface of `CacheStorePolicy.generateFilename` to make it easier to customize your own cache file structure. 89 | 90 | ### Others 91 | 92 | - Updated document and example. 93 | - Fixed some bugs. 94 | 95 | --- 96 | 97 | ## [0.1.3-beta] - Released on 2018-11-10 98 | 99 | - Some bug fixes. 100 | - Document updates. 101 | - Better 0 size files handling. 102 | 103 | --- 104 | 105 | ## [0.1.1-beta] - Released on 2018-11-04 106 | 107 | - Finished basic designs. 108 | - Updated documentation to match requirements 109 | - Started to use in my own project. 110 | -------------------------------------------------------------------------------- /lib/src/policies/least_frequently_used_policy.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import '../cache_store.dart'; 4 | import '../cache_store_policy.dart'; 5 | 6 | /// [CacheItemPayload] to hold [LeastFrequentlyUsedPolicy] data 7 | class LFUPolicy extends CacheItemPayload { 8 | LFUPolicy() : this.hits = []; 9 | LFUPolicy.from(List list, {int chop}) { 10 | hits = list; 11 | if (chop != null) this.chop(chop); 12 | } 13 | 14 | List hits; 15 | 16 | void chop(int now) { 17 | final size = hits.length; 18 | if (size == 0) return; 19 | if (hits[0] > now) return; 20 | if (hits[size - 1] < now) { 21 | hits = []; 22 | return; 23 | } 24 | 25 | for (var i = 1; i < size; i += 1) { 26 | if (hits[i] > now) { 27 | hits = hits.sublist(i); 28 | return; 29 | } 30 | } 31 | } 32 | } 33 | 34 | /// Implements a Least-Frequently-Used Policy. 35 | class LeastFrequentlyUsedPolicy extends CacheStorePolicy { 36 | static const MAX_COUNT = 100 * 1000; // 100k 37 | 38 | /// When reach [maxCount], LFU file will be deleted first. 39 | /// [hitAge] is how long it will take count as "used" after the file been visited. 40 | /// Any `hit` after [hitAge] will expire. 41 | LeastFrequentlyUsedPolicy({ 42 | this.maxCount = 999, 43 | Duration hitAge = const Duration(days: 30), 44 | }) : this.hitAge = hitAge.inSeconds { 45 | if (maxCount <= 0 || maxCount >= MAX_COUNT) 46 | throw RangeError.range(maxCount, 0, MAX_COUNT, 'maxCount'); 47 | } 48 | 49 | final int maxCount; 50 | final int hitAge; 51 | 52 | static const _KEY = 'CACHE_STORE:LFU'; 53 | String get storeKey => _KEY; 54 | 55 | Future saveItems(List items) async { 56 | final timestamps = {}; 57 | items.forEach((item) { 58 | timestamps[item.key] = (item.payload as LFUPolicy).hits; 59 | }); 60 | 61 | await CacheStore.prefs.setString(storeKey, jsonEncode(timestamps)); 62 | } 63 | 64 | Future> cleanup(Iterable allItems) async { 65 | final list = allItems.toList(); 66 | final ts = now(); 67 | for (var i = 0; i < list.length; i += 0) { 68 | (list[i].payload as LFUPolicy).chop(ts); 69 | } 70 | 71 | if (list.length <= maxCount) { 72 | saveItems(list); 73 | return []; 74 | } 75 | 76 | list.sort((a, b) { 77 | final ha = (a.payload as LFUPolicy).hits; 78 | final hb = (b.payload as LFUPolicy).hits; 79 | final cnt = hb.length - ha.length; 80 | 81 | return cnt != 0 ? cnt : ha.isEmpty ? 0 : hb.last - ha.last; 82 | }); 83 | 84 | saveItems(list.sublist(0, maxCount)); 85 | return list.sublist(maxCount); 86 | } 87 | 88 | int now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; 89 | 90 | Future onAccessed(final CacheItem item, bool) async { 91 | item.payload ??= LFUPolicy(); 92 | (item.payload as LFUPolicy).hits.add(now() + hitAge); 93 | } 94 | 95 | Future> restore(List allItems) async { 96 | Map stored = 97 | jsonDecode(CacheStore.prefs.getString(storeKey) ?? '{}'); 98 | 99 | final ts = now(); 100 | return allItems.map((item) { 101 | item.payload = LFUPolicy.from(stored[item.key], chop: ts); 102 | return item; 103 | }); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /lib/src/utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | import 'dart:io'; 3 | 4 | import 'package:http/http.dart' as http; 5 | import 'package:synchronized/synchronized.dart'; 6 | import '../flutter_cache_store.dart'; 7 | 8 | /// Custom Fetch method interface 9 | /// Optional parameter [custom] (`Map`) you can pass with [getFile] 10 | typedef CustomFetch = Future Function(String url, 11 | {Map headers, Map custom}); 12 | 13 | Future _defaultGetter(String url, 14 | {Map headers, Map custom}) => 15 | http.get(Uri.parse(url), headers: headers); 16 | 17 | /// Some helpers for internal usage 18 | class Utils { 19 | static final _rand = Random.secure(); 20 | static const _EFF_TIME_FLAG = 0x2000 * (1 << 32) - 1; // 407+ day 21 | 22 | // 0-9, A..Z, _, `, a..z 23 | static int _c64(final int x) { 24 | if (x < 10) return 48 + x; 25 | if (x < 36) return 65 + x - 10; 26 | return 95 + x - 36; 27 | } 28 | 29 | /// Returns a random number based on timestamp. This number repeat every ~407 days 30 | static int genNow() => DateTime.now().microsecondsSinceEpoch & _EFF_TIME_FLAG; 31 | 32 | /// Returns a random filename with 11 chars based on timestamp 33 | static String genName() { 34 | final codes = List.filled(11, 0); 35 | var x = genUniqId(); 36 | codes[0] = _c64(((x & 0x7000000000000000) >> 60) | (x < 0 ? 8 : 0)); 37 | x &= (1 << 60) - 1; 38 | for (var i = 10; i > 0; i -= 1, x >>= 6) { 39 | codes[i] = _c64(x & 0x3F); 40 | } 41 | return String.fromCharCodes(codes); 42 | } 43 | 44 | /// Generates a random number combined based on timestamp 45 | static int genUniqId() => (_rand.nextInt(0x80000) << 45) | genNow(); 46 | 47 | static final _downloadLocks = {}; 48 | 49 | /// Makes a `GET` request to [url] and save it to [item.fullPath] 50 | /// [url] and Optional [headers] parameters will pass to `http.get` 51 | /// set [useCache] to `false` will force downloading regardless cached or not 52 | /// [onDownloaded] event triggers when downloading is finished 53 | static Future download( 54 | CacheItem item, 55 | bool useCache, 56 | OnDownloaded onDownloaded, 57 | String url, { 58 | CustomFetch fetch, 59 | Map headers, 60 | Map custom, 61 | }) async { 62 | final file = File(item.fullPath); 63 | final key = item.filename; 64 | if (useCache && 65 | await file.exists() && 66 | ((_downloadLocks.containsKey(key) && _downloadLocks[key] == null) || 67 | await file.length() != 0)) { 68 | return file; 69 | } 70 | 71 | var lock = _downloadLocks[key]; 72 | if (lock == null) { 73 | lock = Lock(); 74 | _downloadLocks[key] = lock; 75 | try { 76 | await lock.synchronized(() async { 77 | final results = await Future.wait([ 78 | file.create(recursive: true), 79 | (fetch ?? _defaultGetter)(url, headers: headers, custom: custom), 80 | ]); 81 | 82 | final File f = results.first; 83 | final http.Response response = results.last; 84 | 85 | await Future.wait([ 86 | onDownloaded(item, response.headers), 87 | f.writeAsBytes(response.bodyBytes), 88 | ]); 89 | }); 90 | } finally { 91 | _downloadLocks[key] = null; 92 | } 93 | } else { 94 | await lock.synchronized(() {}); 95 | } 96 | return file; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flutter_cache_store 2 | 3 | A flexible cache manager for Flutter. 4 | 5 | This package is highly inspired by [flutter_cache_manager](https://pub.dartlang.org/packages/flutter_cache_manager). Can be easily switched to each other. 6 | 7 | ## Quick Start 8 | 9 | ```dart 10 | import 'package:flutter_cache_store/flutter_cache_store.dart'; 11 | 12 | void demo(String url) async { 13 | final store = await CacheStore.getInstance(); 14 | final file = await store.getFile(url); 15 | // do something with file... 16 | } 17 | ``` 18 | 19 | --- 20 | 21 | ## APIs 22 | 23 | ### CacheStore 24 | 25 | ```dart 26 | void api() async { 27 | // get store instance 28 | CacheStore store = await CacheStore.getInstance( 29 | namespace: 'unique_name', // default: null - valid filename used as unique id 30 | policy: LeastFrequentlyUsedPolicy(), // default: null - will use `LessRecentlyUsedPolicy()` 31 | clearNow: true, // default: false - whether to clean up immediately 32 | fetch: myFetch, // default: null - a shortcut of `CacheStore.fetch` 33 | ); 34 | 35 | // You can change custom fetch method at anytime. 36 | // Set it to `null` will simply use `http.get` 37 | store.fetch = myFetch; 38 | 39 | // fetch a file from an URL and cache it 40 | File file = await store.getFile( 41 | 'url', // GET method 42 | key: null, // use custom string instead of URL 43 | headers: {}, // same as http.get 44 | fetch: myFetch, // Optional: CustomFunction for making custom request 45 | // Optional: Map any custom you want to pass to your custom fetch function. 46 | custom: {'method': 'POST', 'body': 'test'}, 47 | flushCache: false, // whether to re-download the file 48 | ); 49 | 50 | // flush specific files by keys 51 | await store.flush([ 52 | 'key', // key (default is the URL) passed to `getFile` 53 | ]); 54 | 55 | // remove all cached files 56 | await store.clearAll(); 57 | } 58 | 59 | // Custom fetch function. 60 | // A demo of how you can achieve a fetch supporting POST with body 61 | Future myFetch(url, 62 | {Map headers, Map custom}) { 63 | final data = custom ?? {}; 64 | switch (data['method'] ?? '') { 65 | case 'POST': 66 | { 67 | return post(url, headers: headers, body: data['body']); 68 | } 69 | default: 70 | return get(url, headers: headers); 71 | } 72 | } 73 | ``` 74 | 75 | ### Cache File Structure 76 | 77 | By default, the cached files will be saved under `$TEMP/cache_store`. `$TEMP` is generated by [path_provider](https://pub.dartlang.org/packages/path_provider). The default temp filenames are timestamp-based 11 chars. 78 | 79 | You can customize your own file structure under `$TEMP/cache_store` by overriding Policy. There is an example: 80 | 81 | > Let's suppose your are developing a reader for novels, and your app will cache chapters of books. Your API returns some IDs of books and chapters, and an ID only contains letters and digits. 82 | 83 | ```dart 84 | // Extends a Policy class and override `generateFilename` 85 | class LRUCachePolicy extends LessRecentlyUsedPolicy { 86 | LRUCachePolicy({int maxCount}) : super(maxCount: maxCount); 87 | 88 | @override 89 | String generateFilename({final String key, final String url}) => 90 | key; // use key as the filename 91 | } 92 | 93 | void customizedCacheFileStructure() async { 94 | // get store instance 95 | CacheStore store = await CacheStore.getInstance( 96 | policy: LRUCachePolicy(maxCount: 4096), 97 | namespace: 'my_store', 98 | ); 99 | 100 | // fetch a file from an URL and cache it 101 | String bookId = 'book123'; 102 | String chapterId = 'ch42'; 103 | String chapterUrl = 'https://example.com/book123/ch42'; 104 | File file = await store.getFile( 105 | chapterUrl, 106 | key: '$bookId/$chapterId', // use IDs as path and filename 107 | ); 108 | 109 | // Your file will be cached as `$TEMP/cache_store__my_store/book123/ch42` 110 | } 111 | ``` 112 | 113 | --- 114 | 115 | ## Cache Policy 116 | 117 | - `LessRecentlyUsedPolicy` 118 | 119 | LRU policy. Less Recently Used files will be removed when reached `maxCount`. Each time you access a file will update its used timestamp. 120 | 121 | ```dart 122 | new LessRecentlyUsedPolicy( 123 | maxCount: 999, 124 | ); 125 | ``` 126 | 127 | - `LeastFrequentlyUsedPolicy` 128 | 129 | LFU policy. Least Frequently Used files will be removed when reached `maxCount`. Each time you access a file will increase its hit count. After `hitAge` time the hit will expire. It will fallback to LRU policy if files have same hit count. 130 | 131 | ```dart 132 | new LeastFrequentlyUsedPolicy( 133 | maxCount: 999, 134 | hitAge: Duration(days: 30), 135 | ); 136 | ``` 137 | 138 | - `CacheControlPolicy` 139 | 140 | `Cache-Control` header policy. This policy generally follows `max-age=` or `s-maxage=` rules of http response header [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control). If the max-age-seconds not been set, it will use `minAge` unless you set it to `null`. The age will not be longer than `maxAge`. 141 | 142 | ```dart 143 | new CacheControlPolicy( 144 | maxCount: 999, 145 | minAge: Duration(seconds: 30), // nullable 146 | maxAge: Duration(days: 30), // nullable 147 | ); 148 | ``` 149 | 150 | - `FifoPolicy` 151 | 152 | First-In, First-Out policy, super simple and maybe for example only. 153 | 154 | ```dart 155 | new FifoPolicy( 156 | maxCount: 999, 157 | ); 158 | ``` 159 | 160 | ### Performance Warning 161 | 162 | - The implementation maintains all key-item in memory to improve the speed. So `maxCount` must between 1 and 100000 (100k) due to the cost of RAM and file system. 163 | 164 | - Currently, all the policies simply sort all items to expire files. It may hit performance due to `O(N*logN)` complexity. 165 | 166 | > Will switch to priority queue which has `O(N*logK)` while `K` usually is a very small number. 167 | 168 | ### How to implement your own policy 169 | 170 | The interface is a simple abstract class. You only have to implement a few methods. 171 | 172 | ```dart 173 | abstract class CacheStorePolicy { 174 | // IT'S THE ONLY METHOD YOU HAVE TO IMPLEMENT. 175 | // `store` will invoke this method from time to time. 176 | // Make sure return all expired items at once. 177 | // then `store` will manage to remove the cached files. 178 | // you also have to save your data if need to persist some data. 179 | Future> cleanup(Iterable allItems); 180 | 181 | // will be invoked when store.clearAll called. 182 | Future clearAll(Iterable allItems) async {} 183 | 184 | // will invoke only once when the `store` is created and load saved data. 185 | // you need to load persistent data and restore items' payload. 186 | // only returned items will be cached. others will be recycled later. 187 | Future> restore(List allItems) async => allItems; 188 | 189 | // event when a new `CacheItem` has been added to the cache. 190 | // you may need to attach a `CacheItemPayload` instance to it. 191 | Future onAdded(final CacheItem addedItem) async {} 192 | 193 | // event when an item just been accessed. 194 | // you may need to attach or update item's payload. 195 | Future onAccessed(final CacheItem accessedItem, bool flushed) async {} 196 | 197 | // event when a request just finished. 198 | // the response headers will be passed as well. 199 | Future onDownloaded(final CacheItem item, final Map headers) async {} 200 | 201 | // event when `store.flush` has called 202 | Future onFlushed(final Iterable flushedItems) async {} 203 | 204 | // filename (including path) relative to `CacheItem.rootPath`. 205 | // usually ignore this unless need a better files structure. 206 | // you must provide valid filenames. 207 | String generateFilename({final String key, final String url}) => 208 | Utils.genName(); // timestamp based random filename 209 | } 210 | ``` 211 | 212 | - Tips 213 | 214 | > You don't have to implement all of the `onAdded`, `onAccessed` and `onDownloaded`. 215 | -------------------------------------------------------------------------------- /lib/src/cache_store.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:path_provider/path_provider.dart'; 5 | import 'package:shared_preferences/shared_preferences.dart'; 6 | import 'package:synchronized/synchronized.dart'; 7 | 8 | import 'utils.dart'; 9 | import 'cache_store_policy.dart'; 10 | import 'policies/less_recently_used_policy.dart'; 11 | 12 | /// Must implement sub-class to hold the extra-data you want 13 | abstract class CacheItemPayload {} 14 | 15 | /// Base class to hold cache item data 16 | class CacheItem { 17 | /// [store] is used to indicate the base file path [store.path] 18 | /// [key] is used to identify uniqueness of a file 19 | /// [filename] is relative path and filename to [store.path] 20 | CacheItem({this.store, this.key, this.filename}); 21 | 22 | /// Returns the store owns the item 23 | final CacheStore store; 24 | 25 | /// Returns the unique key of an item 26 | final String key; 27 | 28 | /// Relative path and filename to [rootPath] 29 | final String filename; 30 | 31 | /// Holds extra-data required by a `Policy` 32 | CacheItemPayload payload; 33 | 34 | /// Absolute path of the file 35 | String get fullPath => '${store.path}/$filename'; 36 | 37 | /// Converts it to `JSON` to persist the item on disk 38 | Map toJson() => { 39 | 'k': key, 40 | 'fn': filename, 41 | }; 42 | 43 | /// Creates [CacheItem] from `JSON` data 44 | CacheItem.fromJson(CacheStore store, Map json) 45 | : store = store, 46 | key = json['k'], 47 | filename = json['fn']; 48 | } 49 | 50 | bool isValidCacheItem(CacheItem item) => 51 | item.store != null && 52 | item.key != null && 53 | item.filename != null && 54 | File(item.fullPath).existsSync(); 55 | 56 | /// Singleton object to manage cache 57 | class CacheStore { 58 | /// Unique namespace 59 | final String namespace; 60 | final CacheStorePolicy policyManager; 61 | String get path => namespace == null ? _rootPath : '${_rootPath}__$namespace'; 62 | 63 | /// A simple callback function to customize your own fetch method. 64 | /// You can change it anytime. See its interface: [CustomFetch] 65 | CustomFetch fetch; 66 | 67 | CacheStore._(this.namespace, this.policyManager); 68 | 69 | static final _lockCreation = new Lock(); 70 | static final Map _cacheStores = {}; 71 | static SharedPreferences _prefs; 72 | static String _rootPath; 73 | 74 | /// Public `SharedPreferences` instance 75 | static SharedPreferences get prefs => _prefs; 76 | 77 | static Future _getRootPath() async { 78 | final tmpPath = (await getTemporaryDirectory()).path; 79 | return '$tmpPath/$_DEFAULT_STORE_FOLDER'; 80 | } 81 | 82 | static Future _initStatic() async { 83 | _rootPath ??= await _getRootPath(); 84 | _prefs ??= await SharedPreferences.getInstance(); 85 | } 86 | 87 | /// Returns [CacheStore] instance, all parameters are optional 88 | /// [namespace] is unique key and must be a valid filename 89 | /// [policy] is [CacheStorePolicy] you want to use, [LessRecentlyUsedPolicy] by default 90 | /// Set [clearNow] to `true` will immediately cleanup 91 | /// [fetch] is a shortcut to set [CacheStore.fetch] 92 | static Future getInstance({ 93 | final String namespace, 94 | final CacheStorePolicy policy, 95 | final bool clearNow = false, 96 | final CustomFetch fetch, 97 | }) async { 98 | CacheStore instance; 99 | await _lockCreation.synchronized(() async { 100 | instance = _cacheStores[namespace]; 101 | if (instance != null) return; 102 | 103 | await _initStatic(); 104 | 105 | instance = CacheStore._(namespace, policy ?? LessRecentlyUsedPolicy()); 106 | instance.fetch = fetch; 107 | await instance._init(clearNow); 108 | }); 109 | 110 | return instance; 111 | } 112 | 113 | static const _PREF_KEY = 'CACHE_STORE'; 114 | static const _DEFAULT_STORE_FOLDER = 'cache_store'; 115 | 116 | String get prefKey => namespace == null ? _PREF_KEY : '$_PREF_KEY/$namespace'; 117 | 118 | Future _init(final bool clearNow) async { 119 | final Map data = 120 | jsonDecode(prefs.getString(prefKey) ?? '{}'); 121 | final items = (data['cache'] as List ?? []) 122 | .map((json) => CacheItem.fromJson(this, json)) 123 | .toList() 124 | .where(isValidCacheItem) 125 | .toList(); 126 | 127 | (await policyManager.restore(items)) 128 | .forEach((item) => _cache[item.key] = item); 129 | 130 | if (clearNow) { 131 | await _cleanup(); 132 | } 133 | } 134 | 135 | final _cache = {}; 136 | 137 | /// Returns `File` based on unique [key] from cache first, by default. 138 | /// [key] will use [url] (including query params) when omitted. 139 | /// A `GET` request with [headers] will be sent to [url] when not cached. 140 | /// Set [flushCache] to `true` will force it to re-download the file. 141 | /// Optional [fetch] to override [CacheStore.fetch] for downloading. 142 | /// Optional [custom] data to pass to [fetch] or [CacheStore.fetch] function. 143 | Future getFile( 144 | final String url, { 145 | final Map headers, 146 | final Map custom, 147 | final String key, 148 | final CustomFetch fetch, 149 | final bool flushCache = false, 150 | }) async { 151 | final item = await _getItem(key, url); 152 | policyManager.onAccessed(item, flushCache); 153 | _delayCleanUp(); 154 | return Utils.download(item, !flushCache, policyManager.onDownloaded, url, 155 | fetch: fetch ?? this.fetch, headers: headers, custom: custom); 156 | } 157 | 158 | /// Forces to delete cached files with keys [urlOrKeys] 159 | /// [urlOrKeys] is a list of keys. You may omit the key then will be the URL 160 | Future flush(final List urlOrKeys) { 161 | final items = urlOrKeys 162 | .map((key) => _cache[key]) 163 | .where((item) => item != null) 164 | .toList(); 165 | final futures = items.map(_removeFile).toList(); 166 | futures.add(policyManager.onFlushed(items)); 167 | return Future.wait(futures); 168 | } 169 | 170 | final _itemLock = new Lock(); 171 | 172 | Future _getItem(String key, String url) async { 173 | final k = key ?? url; 174 | var item = _cache[k]; 175 | if (item != null) return item; 176 | 177 | await _itemLock.synchronized(() { 178 | item = _cache[k]; 179 | if (item != null) return; 180 | 181 | final filename = policyManager.generateFilename(key: key, url: url); 182 | item = CacheItem(store: this, key: k, filename: filename); 183 | _cache[k] = item; 184 | policyManager.onAdded(item); 185 | }); 186 | return item; 187 | } 188 | 189 | bool _delayedCleaning = false; 190 | final _cleanLock = new Lock(); 191 | int _lastCacheHash; 192 | 193 | Future _removeFile(CacheItem item) async { 194 | final file = File(item.fullPath); 195 | if (await file.exists()) { 196 | await file.delete(); 197 | } 198 | } 199 | 200 | Future _cleanup() => _cleanLock.synchronized(() async { 201 | final expired = await policyManager.cleanup(_cache.values); 202 | expired.forEach((item) { 203 | _cache.remove(item.key); 204 | }); 205 | 206 | final cacheString = jsonEncode({'cache': _cache.values.toList()}); 207 | if (_lastCacheHash == cacheString.hashCode) return; 208 | 209 | _lastCacheHash = cacheString.hashCode; 210 | await Future.wait([ 211 | prefs.setString(prefKey, cacheString), 212 | _removeCachedFiles(), 213 | ]); 214 | }); 215 | 216 | static const _DELAY_DURATION = Duration(seconds: 60); 217 | 218 | Future _delayCleanUp() async { 219 | if (_delayedCleaning) return; 220 | 221 | _delayedCleaning = true; 222 | await Future.delayed(_DELAY_DURATION, () async { 223 | _delayedCleaning = false; 224 | await _cleanup(); 225 | }); 226 | } 227 | 228 | /// Removes all cached files and persisted data on disk. 229 | /// This method should be invoked when you want to release some space on disk. 230 | Future clearAll() => _cleanLock.synchronized(() async { 231 | final items = _cache.values.toList(); 232 | _cache.clear(); 233 | 234 | await Future.wait([ 235 | _removeCachedFiles(all: true), 236 | policyManager.clearAll(items), 237 | ]); 238 | }); 239 | 240 | Future _removeCachedFiles({bool all = false}) async { 241 | final ts = DateTime.now(); 242 | final dir = Directory(path); 243 | if (!await dir.exists()) return; 244 | 245 | final entries = 246 | await dir.list(recursive: false, followLinks: false).toList(); 247 | final items = Set.from(all ? [] : _cache.values.map((x) => x.fullPath)); 248 | 249 | entries.forEach((entry) async { 250 | if (items.contains(entry.path)) return; 251 | if (await FileSystemEntity.isDirectory(entry.path)) return; 252 | 253 | final file = File(entry.path); 254 | if (!all && ts.isBefore(await file.lastModified())) return; 255 | file.delete(recursive: false); 256 | }); 257 | } 258 | } 259 | --------------------------------------------------------------------------------