- Notifications
You must be signed in to change notification settings - Fork0
A Dart library for data loading, validation, and caching
License
motorro/DartLceModel
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
A reactive data loading for Dart platform to load data and report anoperation state (Loading
/Content
/Error
).
WORK IN PROGRESS. Refer toAndroid library docs to get an overview
- Widely used design with
Loading
/Content
/Error
states - Uses cache as a 'source of truth' withCacheThenNetLceModel.
- Checks data is valid (up-to-date or whatever).
- Falls back to invalid cache data if failed to refresh which allows offline application use.
- Supports datarefresh orupdate to implement reload or server data update operations.
- Cache may beinvalidated separately from loading to allow lazy data updates and complex data linking.
- Extendable architecture on every level.
- Thoroughly tested.
The project contains an example app that:
- loads a list of GitHub user repositories, caching resulting data
- externally invalidates cache that makes active listeners to reload data
- Setting up the dependency
- LceState
- LceModel
- CacheThenNetLceModel
- Getting and caching data
- Choosing EntityValidator
- Displaying 'invalid' data and cache fall-back
- Cache invalidation and data updates
- On-demand cache refetch
- Cache service implementation
- A complete example of model setup in a widget
- Getting data-only stream and stream transformations
- State transformations
$ dart pub add dartlcemodel
A modern approach to architecting the reactive application suggests packing the combined state of the application into aflow of immutable state-objects. Each of them should contain the whole set of data required to process, transform,and display according to the business requirement. The most commonly used information besides the data itself is a stateof data-loading pipeline.
EachLceState<DATA, PARAMS>
subclass represents a data-loading phase and contains the following data:
DATA? data
- Loaded databool dataIsValid
- The validity of data at the time of emission. May be used by caching services to indicatethe need of data refresh. More about it inCacheThenNetLceModel section.
States being emitted are:
Loading
- data is being loaded or updated. May contain some data. The exact state is defined bytype
property:
/// Loading typeenumLoadingType {/// Just loads. May be initial load operation loading,/// Loading more items for paginated view loadingMore,/// Refreshing content refreshing,/// Updating data on server updating}
Content
- data is loaded.Error
- some error while loading or updating data. May also contain some data.Terminated
- a special state to indicate that resource identified byparams
is not available anymore.
LceState<DATA, PARAMS>
in this library is being produced by the simple use-caseinterface:
/// Base LCE use-case with[state] and[refresh]///[DATA] Data type of data being loadedabstractclassLceUseCase<DATAextendsObject> {/// Model state. Subscription starts data load for the first subscriber. /// Whenever last subscriber cancels, the model unsubscribes internal components for data updatesStream<LceState<DATA>>get state;/// Requests a refresh of data. /// Data will be updated asynchronouslyFuture<void>refresh();}
The use-case contains the following properties:
state
- theStream
that emitsLceState
refresh
- theFuture
to perform data refresh
The direct extension of the use-case is theLceModel that binds the expected data withthe data identifyingPARAMS
:
/// A model interface to load data and transmit it to subscribers along with loading operation state/// The model is bound with[params] that identify the data///[DATA] Data type of data being loaded///[PARAMS] Params type that identify data being loadedabstractclassLceModel<DATAextendsObject,PARAMSextendsObject>implementsLceUseCase<DATA> {/// Params that identify data being loadedPARAMSget params;}
As you may see, parameters for model is a property - thus making the model immutable itself. This approach makesthings a bit easier in many cases like:
- When you share your model and it's emission
- You don't need to supply a Stream for
PARAMS
which complicates design a lotIf you need dynamic params - just flat-map your params by creating a new model for each parameter value like this:
Stream<string> params=Stream.fromIterable(['peach','banana','apple']);Stream<LceState<FruitData>> state= params.asyncExpand((params)=>createModel(params).state);
As you may guess from its name this kind of model tries to get cached data first and then loads data from network ifnothing is found or the cached data is stalled. Here is the sequence diagram of data loading using this type of model:
The model creates a data stream for givenPARAMS
in acache-serviceand transmits it to subscriber. If cache does not contain any data or data is not valid (more on validation later) themodel subscribes anet-service to download data from network and saves itto the cache for a later use.
It is worth noting thatcache
andnet
here is just a common use-case of data-sources: locally stored data (cache
)and some data that is maybe not that easy to get (net
). You may easily adopt data sources of your choice to thatapproach. Say you have a resource-consuming computation result which may be cached and consumed later. The computationitself than becomes anet-service
while the result is being stashed to acache-service
ofyour choice for later reuse.
To create newCacheThenNet
model call a factory function:
final useCase=LceModel.cacheThenNet('params',// params that identify the data being loaded serviceSet,// A set of cache + net services (see below) startWith:constLceState.loading(null,false),// Optional initial state to emit at subscription logger: logger// Optional logger to get what's going on inside the use-case);
As already mentioned above caching model uses two services to get data from network and to store it locally.
- NetService - loads data from network.
- CacheService - saves data locally.
Caching data always brings up a problem of cache updates and invalidation. Be it a caching policy of your backend teamor some internal logic of your application the data validity evaluation may be easily implemented:
TheNetService
retrieved data and packages it toEntitywrapper - effectively the data itself and someEntityValidator
to provide information when data expires.Validator is a simple interface with only three essential methods:
bool isValid()
- being used by loading pipeline to determine if data is still validstring serialize()
- being called byCacheService
to save data validation parameters along with dataEntityValidator createSnapshot()
- creates a 'snapshot' ofisVAlid()
value at the moment of creation (moreabout it later).
The resultingEntity
is then saved usingCacheService
preserving the data itself and the way to tell when dataexpires.
To convertAny
data toEntity
within your services use the following function:
EntityValidatorcreateValidator() {// Create a validator}val data="Some data";val entity= data.toEntity(createValidator());
There are some validatorsavailable already:
Simple
- just a data-class that is initialized with boolean validity status.Never
- never valid.Always
- always valid.Lifespan
- A validator that is initialized with Time-To-Live value and becomes invalid after itexpires.
While the first three of above-listed validators are easy to use and intuitive the last one needs to be explained.Lifespan
when created gets a reference to a system clock and evaluates its validity against it every time it is beingasked of it. ThusLifespan
is not an immutable and is an object with a self-changing internal state. A validEntity
withLifespan
validator that just seats in memory will expire eventually and become non-valid. That may be a desiredbehavior however in most cases the most useful way to deal with validity is to take a snapshot of data state at the timedata is being emitted from theCacheService
. To be able to do this bothEntityValidator
andEntity
wrappers bothhavecreateSnapshot()
methods that fix the validation status at the time of function is called.
To create aLifeSpan
validator, there is a helper-factory that takes a single parameter of TTL in a constructor:
// Creates validators that are valid for 5 secondsfinal validatorFactory=EntityValidatorFactory.lifespan(5000);
TheLifespanValidatorFactory
is an implementation ofEntityValidatorFactorythat you may implement in case you need your own custom validator.
Having a cache of required data, besides eliminating extra network calls, gives us the ability to fall back to cached datain case network is not available and to keep working. This is an easy way to create an offline-capable mobile app whencomplex state synchronization between the app and server is not required. With 'cache-then-net' model you get the cachefall-back already implemented. Here is what you get when network connection is not available:
When there is no cached data available you just getnull
fordata
property in emittedLceState.Error
.
A common task in complex applications may be the need to refresh some data in a part of application whenever somethinghappens in another part. Reloading a list of messages in a chat application when push arrives may be a simple example.There are different ways of doing this - event-buses, Stream-subjects, you name it.With reactive cache-service the library provides, such an invalidation is made in a simple and clean way:
If the push-message brings a payload that is enough to display data change you could simply save the new data to cashwithsave
method or delete it withdelete
method ofCacheService interface:
The sample application demonstrates cache invalidation with a click orRefresh
button. Here is how the invalidation isimplemented:
/// Globally available service-setlateServiceSet<List<Repository>,String> serviceSet;voidmain() {// Creates a set of services// - memory cache for data// - a service to get data from server. `isolated()` runs networking and parsing in [Isolate] serviceSet=ServiceSet(CacheService<List<Repository>,String>.withSyncDelegate(MemoryCacheDelegate.map()),RepositoryNetService().isolated() );runApp(constMyApp());}// Later in widgetfinal refresh=GestureDetector( onTap: ()async {await serviceSet.cache.invalidateAll(); }, child:constIcon(Icons.refresh, size:26.0, ));
Consider a cache service with complex internal structure that is updated by some internal logic.For example a database that saves entities and something that updates records directly.In case of Room you may observe a query and get updates if something changes underneath. But sometimesyou have a complex entity with relations that are not so easy to fetch as they need conditional processingin synchronous way.In this case you may write an SQL delegate for sync-delegate service (see below) to implement reactive cache.When you get/put the whole entity the solution works. But as soon as you start to update entity partsyou need some way to notify subscribers of data change.
CacheServicehas two methods that when called makes it to refetch data and update its active clients:
Future<void> refetch(P params): Completable
- makes cache service to refetch data forparams
and update corresponding clientsFuture<void> refetchAll()
- makes cache service to refetch data for all subscribers
While you can implement any cache-service you like the library comes with simpleAsyncDelegateCacheServiceandSyncDelegateCacheService which use the following async/sync delegates for data IO:
The interface is self-explanatory and does all the IO forCacheService
in sync/async way.
There is a simple in-memory cache service available so far. Disk cache port is awork in progress.To create an in-memory cache, use the following:
final cache=CacheService<SomeData,String>.withSyncDelegate(MemoryCacheDelegate.map());
Here is a complete setup ofLceModel
in a widget :
classLceWidget<DextendsObject>extendsStatefulWidget {finalString params;constLceWidget({Key? key,requiredthis.params}):super(key: key);@overrideState<LceWidget<D>>createState()=>_LceWidgetState();}class_LceWidgetState<DextendsObject>extendsState<LceWidget<D>> {LceUseCase<D>? _useCase;StreamSubscription<LceState<D>>? _subscription;lateLceState<D> _lceState;@overridevoidinitState() {super.initState();_subscribe(); }@overridevoiddidUpdateWidget(LceWidget<D> oldWidget) {super.didUpdateWidget(oldWidget);// Check if we need to load another dataif (widget.params!= oldWidget.params) {_unsubscribe();_subscribe(); } }@overridevoiddispose() {super.dispose();_unsubscribe(); }_subscribe() {// Initial state to display _lceState=constLceState.loading(null,false); _useCase=LceModel.cacheThenNet( widget.params,// params that identify the data being loaded serviceSet// A set of cache + net services - defined globally or provided with DI ); _subscription= _useCase!.state.listen((newLceState) {setState(() { _lceState= newLceState; }); }); }_unsubscribe() { _subscription?.cancel(); _subscription=null; _useCase=null; }Future<void>_refresh()async {return _useCase?.refresh()??Future.value(); }@overrideWidgetbuild(BuildContext context)=> lceState.when( loading: (_)=>constLoadingState(),// Widget for loading content: (state)=>ContentState(repositories: state.data),// Widget for content error: (state)=>ErrorState(error: state.error, onRetry: ()async {await_refresh(); })// Error widget );}
You may transform thestate
propertyStream
to strip state information and to get only the data. The libraryships with some functions already implemented like:
dataWithErrors
- emits data emitting stream error on any errordataWithEmptyErrors
- emits data and emits an error only if there is no data in original emission(LceError
withnull
fordata
property)dataNoErrors
- emits data and ignores errorsvalidData
- emits data only if it is valid
More information and the complete list of extensions may be found in generated documentation or thesource code and tests.
Sometimes you need to mix several LCE streams from different sources or transform a data. For that there are severalextensionsavailable to map and combine them. For example:
map
- maps state data to another type with a mappermapEmptyDataItem
- replaces the empty data with the default itemcombine
- combines one state with another and presenting the 'average' value of both
About
A Dart library for data loading, validation, and caching