59f9c68342
* Update SDK, build tools, gradle, AGP, Kotlin, and library dependencies. * Update travis config with new SDK version. |
||
---|---|---|
.. | ||
README.md |
Store
Store — это легкая в использовании библиотека для реактивной загрузки данных под Android.
Проблемы:
- Современные Android-приложения нуждаются в удобном и всегда доступном представлении данных.
- Пользователи ожидают, что загрузка данных не будет мешать их взаимодействию с приложением. И от социальных, и от новостных, и от business-to-business приложений, пользователи ожидают бесшовного взаимодействия как при подключении к сети, так и в автономном режиме.
- В международном роуминге большой объем загружаемых данных может привести к астрономическим счетам за связь.
Store представляет собой класс, который упрощает загрузку, парсинг, хранение и извлечение данных в вашем приложении. Store похож на паттерн «репозиторий» [https://msdn.microsoft.com/en-us/library/ff649690.aspx], в дополнение предоставляя реактивный API, реализованный с помощью RxJava, который придерживается однонаправленного потока данных.
Store обеспечивает уровень абстракции между элементами UI и операциями с данными.
Обзор
Store отвечает за управление загрузкой конкретного запроса данных.
Когда вы создаёте новую реализацию Store, вы предоставляете ему Fetcher
— функцию, которая определяет, как будет происходить загрузка данных из сети.
Также вы можете настроить, как Store будет кэшировать данные в памяти и на диске, а так же то, как будет происходить их парсинг.
Store возвращает данные в виде Observable
, обеспечивая удобную работу с потоками.
Созданный Store управляет логикой обработки потока данных, позволяя элементам пользовательского интерфейса использовать наиболее подходящий их источник, и гарантирует, что новейшие данные будут доступны для последующего использования в автономном режиме.
Store может использовать классы для промежуточной обработки данных (парсинг и кэширование), идущие в комплекте, или же использовать ваши собственные реализации.
Store использует RxJava и «склеивание» множественных запросов, чтобы минимизировать количество обращений к источнику данных в сети и кэшу на диске. Используя Store, с помощью двух уровней кэширования (память и диск), вы исключите ситуации, когда один и тот же сетевой запрос будет выполняться избыточное количество раз.
Полностью сконфигурированный Store
Для начала рассмотрим, как выглядит полностью сконфигурированный Store. Затем последуют более простые примеры, демонстрирующие каждую деталь в отдельности.
Store<ArticleAsset, Integer> articleStore = StoreBuilder.<Integer, BufferedSource, ArticleAsset>parsedWithKey()
.fetcher(articleId -> api.getArticleAsBufferedSource(articleId)) //OkHttp responseBody.source()
.persister(FileSystemPersister.create(FileSystemFactory.create(context.getFilesDir()),pathResolver))
.parser(GsonParserFactory.createSourceParser(gson, ArticleAsset.Article.class))
.open();
Используя указанную выше конфигурацию вы получите:
- Кэш в памяти, использующийся при смене конфигурации (например, при повороте экрана)
- Кэш на диске для использования в автономном режиме
- Парсинг через потоковый API, чтобы ограничить использование памяти
- Многофункциональный API для запроса данных: получение кэшированных/новых данных или подписка на их будущие обновления.
А теперь более подробно:
Создание Store
Для создания Store используется паттерн builder (строитель).
Единственный обязательный параметр — это .Fetcher<ReturnType,KeyType>
, который содержит единственный метод fetch(key)
, возвращающий Observable<ReturnType>
.
Store<ArticleAsset, Integer> store = StoreBuilder.<ArticleAsset,Integer>key()
.fetcher(articleId -> api.getArticle(articleId)) //OkHttp responseBody.source()
.open();
Store использует типизированные ключи в качестве идентификаторов для данных.
Ключом может быть любой объект-значение (value object), который должным образом реализует методы toString()
, equals()
и hashCode()
.
При вызове вашей Fetcher
-функции, ей будет передан конкретный экземпляр ключа.
Аналогичным образом, ключ будет использоваться в качестве основного идентификатора в кэше (убедитесь, что ваш ключ правильно реализует hashCode()
!)
Наша реализация ключа — BarCode
Для удобства мы включили в библиотеку нашу собственную реализацию ключа, называемую BarCode (штрихкод).
Barcode имеет два поля: ключ String key
и тип String type
.
BarCode barcode = new BarCode("Article", "42");
При использовании BarCode в качестве ключа, удобно использовать соответсвующий метод StoreBuilder:
Store<ArticleAsset, Integer> store = StoreBuilder.<ArticleAsset>barcode()
.fetcher(articleBarcode -> api.getAsset(articleBarcode.getKey(),articleBarcode.getType()))
.open();
Публичный интерфейс: Get, Fetch, Stream, GetRefreshing
Observable<Article> article = store.get(barCode);
В первый раз, когда вы подпишитесь на store.get(barCode)
, ответ будет сохранён в кэше в памяти.
Все последующие вызовы store.get(barCode)
с тем же ключом будут возвращать кэшированную версию данных, минимизируя ненужные запросы.
Это позволяет предотвратить загрузку свежих данных из сети (или другого внешнего источника), чтобы уменьшить расход трафика и экономить заряд батареи.
Хороший пример использования: пересоздание пользовательского интерфейса (activity или fragment) после поворота устройства — будут загружены кэшированные данные из Store.
Использование Store поможет вам избежать необходимости реализовывать эту логику на уровне представления.
Поток данных, которым управляет Store, на данный момент будет выглядеть следующим образом:
По умолчанию, 100 элементов будут сохранены в памяти на 24 часа. Вы можете передать свой экземпляр Guava Cache, чтобы переопределить стандартную политику.
Запрос свежих данных
В качестве альтернативы, вы можете вызвать store.fetch(barCode)
, чтобы получить Observable
, игнорируя кэш в памяти (и кэш на диске, если он используется).
Получение свежих данных выглядит следующим образом: store.fetch()
В приложении The New York Times фоновое обновление, выполняемое ночью, использует fetch()
, чтобы во время обычного использования приложения вызов store.get()
не приводил к сетевым запросам.
Другой хороший пример использования fetch()
— обработка жеста pull-to-refresh, когда пользователь сам запрашивает обновление данных.
Методы fetch()
и get()
порождают одно значение и затем вызывают onCompleted()
или выбрасывают исключение в случае ошибки.
Поток
Для обновлений в реальном времени вы также можете вызвать метод store.stream()
, который возвращает Observable
, который порождает событие при сохранении нового объекта в Store.
Вы можете рассматривать этот поток как шину событий (Event Bus), позволяющую узнавать, когда происходят новые удачные обращения к сети для определённого Store.
Вы можете использовать оператор RxJava filter()
, чтобы подписаться только на определенные события.
Получение обновляющихся данных
Существует еще один специальный способ подписаться на Store: getRefreshing(key)
.
getRefreshing()
подпишется на get()
, который возвращает один результат, но в отличие от get()
, getRefreshing()
останется подписанным.
Каждый раз после вызова store.clear(key)
все подписчики getRefreshing(key)
подпишутся заново и принудительно создадут новый сетевой запрос данных.
Оптимизация запросов
Store предлагает дополнительные механизмы для предотвращения дублирующихся запросов одних и тех же данных.
Если некий запрос выполняется в течение минуты после предыдущего точно такого же запроса, возвращается тот же ответ.
Это полезно в ситуациях, когда вашему приложению во время запуска требуется сделать много асинхронных вызовов для одних и тех же данных или когда пользователь интенсивно запрашивает обновление данных.
Например, новостное приложение The New York Times при запуске вызывает ConfigStore.get()
из 12 разных мест.
Первый из запросов выполняется, а остальные ожидают поступления данных.
Мы увидели значительное сокращение объема использованных данных приложением после реализации этой логики.
Добавление парсера
Поскольку данные из сети редко поступают в нужном формате, Store может делегировать их обработку парсеру с помощью StoreBuilder.<BarCode, BufferedSource, Article>parsedWithKey()
Store<Article,Integer> store = StoreBuilder.<Integer, BufferedSource, Article>parsedWithKey()
.fetcher(articleId -> api.getArticle(articleId))
.parser(source -> {
try (InputStreamReader reader = new InputStreamReader(source.inputStream())) {
return gson.fromJson(reader, Article.class);
} catch (IOException e) {
throw new RuntimeException(e);
}
})
.open();
Теперь обновленный поток данных будет выглядеть следующим образом:
Промежуточная обработка данных — GsonSourceParser
Отдельный артефакт предоставляет парсеры, использующие Gson, которые могут быть полезны, если Fethcer
возвращает Reader, BufferedSource или String:
- GsonReaderParser
- GsonSourceParser
- GsonStringParser
Они доступны через класс фабрики (GsonParserFactory).
Наш пример можно переписать так:
Store<Article,Integer> store = StoreBuilder.<Integer, BufferedSource, Article>parsedWithKey()
.fetcher(articleId -> api.getArticle(articleId))
.parser(GsonParserFactory.createSourceParser(gson, Article.class))
.open();
В некоторых случаях вам может потребоваться проанализировать JSONArray верхнего уровня, в этом случае вы можете передать TypeToken.
Store<List<Article>,Integer> store = StoreBuilder.<Integer, BufferedSource, List<Article>>parsedWithKey()
.fetcher(articleId -> api.getArticles())
.parser(GsonParserFactory.createSourceParser(gson, new TypeToken<List<Article>>() {}))
.open();
Также существуют артефакты парсеров для Moshi и Jackson!
Кэширование на диске
Можно включить кэширование данных на диске, используя экземпляр класса Persister
при создании Store.
Всякий раз после выполнения сетевого запроса Store будет сохранять данные на диск, а затем считывать их.
Поток данных будет выглядеть так:
store.get()
->
Идеальным вариантом будет, если данные будут передаваться из сети на диск с использованием BufferedSource или Reader в качестве типа данных (а не String).
Store<Article,Integer> store = StoreBuilder.<Integer, BufferedSource, Article>parsedWithKey()
.fetcher(articleId -> api.getArticles())
.persister(new Persister<BufferedSource>() {
@Override
public Observable<BufferedSource> read(Integer key) {
if (dataIsCached) {
return Observable.fromCallable(() -> userImplementedCache.get(key));
} else {
return Observable.empty();
}
}
@Override
public Observable<Boolean> write(BarCode barCode, BufferedSource source) {
userImplementedCache.save(key, source);
return Observable.just(true);
}
})
.parser(GsonParserFactory.createSourceParser(gson, Article.class))
.open();
Stores не заботятся о том, как вы сохраняете или извлекаете данные с диска.
В результате вы можете использовать Store с хранилищем объектов или любой базой данных (Realm, SQLite, CouchDB, Firebase etc).
Единственное требование заключается в том, что данные должны быть одного и того же типа при сохранении/получении, что и возвращаемое функцией Fetcher значение.
Теоретически, ничто не мешает вам реализовать свою версию класса Persister
, который будет использовать для кэширования данных только память устройства.
В таком случае в памяти будут храниться не один а два уровня кэша: первый с оригинальными данными, а второй с распарсенными, что позволит делиться кэшем Persister
между разными экземплярами Store
Примечание: При использовании парсера и кэша на диске, парсер будет вызван ПОСЛЕ получения данных с диска, а не между успешным сетевым вызовом и сохранением на диск.
Это позволяет классу Persister
работать непосредственно с сетевым потоком.
При использовании SQLite мы рекомендуем библиотеку SqlBrite.
Если вы не используете SqlBrite, вы можете создать Observable
с помощью Observable.fromCallable(() -> getDBValue())
Промежуточный слой — SourcePersister и FileSystem
Мы установили, что наибольшей скорости сохранения можно добиться, выполняя потоковое сохранение данных из сети.
Поэтому мы включили отдельную библиотеку, предлагающую реактивную файловую систему, использующую BufferedSource
из библиотеки Okio.
Также артефакт этот включает в себя FileSystemPersister
, реализующий дисковый кэш для Store и прекрасно работающий с GsonSourceParser
.
При использовании FileSystemPersister
нужно передать ему реализацию PathResolver
, определяющую пути к элементам кэша в файловой системе.
Вернемся к первому примеру:
Store<Article,Integer> store = StoreBuilder.<Integer, BufferedSource, Article>parsedWithKey()
.fetcher(articleId -> api.getArticles(articleId))
.persister(FileSystemPersister.create(FileSystemFactory.create(context.getFilesDir()),pathResolver))
.parser(GsonParserFactory.createSourceParser(gson, String.class))
.open();
Как уже упоминалось, это то, как мы работаем с сетевыми операциями в The New York Times. Используя конфигурацию, указанную выше, вы получаете:
- Кэширование в памяти с помощью Guava Cache
- Кэширование на диске с помощью FileSystem (вы можете переиспользовать одну и ту же файловую систему для всех Store)
- Парсинг данных из
BufferedSource
в<T>
(Класс Article в нашем примере) с помошью Gson - Оптимизация запросов
- Возможность использовать кэшированные данные или запросить свежие (методы
get()
иfresh()
) - Возможность подписаться на все новые элементы, полученные из сети (метод
stream()
) - Возможность получить уведомление об очистке кэша, а также переподписаться после этого (метод
getRefreshing()
). Полезно, если нужно выполнить запрос типа POST, после чего другой обновить экран)
Мы рекомендуем использовать эту конфигурацию для большинства хранилищ Store.
Благодаря передаче данных из сети на диск и с диска в парсер в формате потока байтов, SourcePersister
потребляет малое количество памяти.
Это позволяет нам загружать десятки ответов в формате json размером более 1mb и не беспокоиться об исключениях OutOfMemory на устройствах с малым объемом памяти.
Как упоминалось выше, использование Store позволяет нам делать такие вещи, как вызов configStore.get()
множествно раз асинхронно, прежде чем наша MainActivity закончит загрузку, не блокируя основной поток и не загружая сеть.
RecordProvider
Если вы хотите, чтобы ваш Store знал об устаревании данных на диске, ваш Persister
должер реализовывать интерфейс RecordProvider
.
После этого, вы можете настроить работу Store одним из двух способов:
store = StoreBuilder.<BufferedSource>barcode()
.fetcher(fetcher)
.persister(persister)
.refreshOnStale()
.open();
refreshOnStale
инвалидирует дисковый кэш, каждый раз когда данные устареют.
Пользователь получит устаревшие данные.
Или же:
store = StoreBuilder.<BufferedSource>barcode()
.fetcher(fetcher)
.persister(persister)
.networkBeforeStale()
.open();
networkBeforeStale
— Store попытается использовать сетевой источник, если данные устарели.
Если сетевой источник данных выбрасывает исключение или возвращает пустой ответ, Store будет использовать устаревшие данные.
Создание подклассов Store
Можно отнаследоваться от класса реализации Store (RealStore<T>
):
public class SampleStore extends RealStore<String, BarCode> {
public SampleStore(Fetcher<String, BarCode> fetcher, Persister<String, BarCode> persister) {
super(fetcher, persister);
}
}
Это может быть полезно, если вы хотите внедрить зависимость Store или добавить несколько вспомогательных методов:
public class SampleStore extends RealStore<String, BarCode> {
@Inject
public SampleStore(Fetcher<String, BarCode> fetcher, Persister<String, BarCode> persister) {
super(fetcher, persister);
}
}
Артефакты
Примечание: релизы синхронизированы с состоянием ветки master (а не develop).
-
Cache Кэш, извлеченный из библиотеки Guava (чтобы сократить количество методов)
implementation 'com.nytimes.android:cache:CurrentVersion'
-
Store Содержит только классы Store, зависит от RxJava и артефакта кэша
implementation 'com.nytimes.android:store:CurrentVersion'
-
Middleware Парсеры Gson (не стесняйтесь создавать новые и предлагать PR)
implementation 'com.nytimes.android:middleware:CurrentVersion'
-
Middleware-Jackson Парсеры Jackson (не стесняйтесь создавать новые и предлагать PR)
implementation 'com.nytimes.android:middleware-jackson:CurrentVersion'
-
Middleware-Moshi Парсеры Moshi (не стесняйтесь создавать новые и предлагать PR)
implementation 'com.nytimes.android:middleware-moshi:CurrentVersion'
-
File System библиотека, использующая Okio Source/Sink + Middleware для сохранения потока данных из сети в файловую систему
implementation 'com.nytimes.android:filesystem:CurrentVersion'
Пример проекта
Директория app содержит тестовое приложение, демонстрирующее использование Store. Кроме того, вики этого репозитория содержит некоторые рецепты для распространенных сценариев использования:
- Простой пример: Retrofit + Store
- Сложный пример: BufferedSource и Retrofit (или OkHTTP) + кэш на диске с FileSystem + GsonSourceParser