Сегодняшнюю статью я посвящу кэшированию в андроид. Для того, чтобы понять, что тут происходит, вам нужно знать, что такое MVVM, Room, Retrofit, DI(Hilt), Coroutines и Lifecycle.
-> Предоставлю сразу ссылку на проект, с которым сегодня мы будем работать: https://github.com/evgenkr47/ExampleRoomCaching <-
P.S: в приложении не используется Clean Architecture, так как это займет намного больше времени, о чистой архитектуре мы поговорим с вами в отдельной статье.
Какие задачи решает локальное кэширование:
Мы с вами получаем какие-то данные из интернета, например список продуктов, используя API. Эти данные будут загружаться в локальную базу данных при помощи базы данных Room. Данная практика даёт нам прекрасные возможности, а именно:
1. Мы не будем каждый раз обращаться к нашему API за загрузкой данных, а будем моментально отображать наши данные из кэша. Во первых, на загрузку данных нужно определенное время, и пользователю будет не очень приятно каждый раз ожидать, когда же данные загрузятся(даже если загрузка занимает пару секунд, согласитесь, все равно не очень приятно). Во вторых, у пользователя могут быть проблемы с интернетом, например, плохое соединение, что в свою очередь сделает загрузку еще более долгой.
2. Важный момент - это работа приложения без интернета, если мы будем просто получать какие-то данные из интернета не кэшируя их локально, после чего отключим интернет и попытаемся перезапустить приложение, то приложение не запустится и упадет с ошибкой.
Надеюсь вы согласны, что отсутствие кэширования может вызвать раздражение у пользователя и последующее удаление вашего приложения с телефона.
Как выглядит схема алгоритма кэширования:
Теперь перейдём к конкретному примеру, у нас есть простое приложение, которое выгружает в recyclerview список продуктов(пицца):
Первое с чего мы начнем это с создание макета xml. Там все максимально просто вверху у нас TextView - "Pizza", к которому прикреплен RecyclerView c id "recyclerView", помимо этого на макете присутствует по середине ProgressBar(который будет отображаться при загрузке данных и скрываться после их загрузки) с id "progressBar" и visibility gone и еще одним TextView(который будет отображаться при ошибке получения данных из интернета) по середине с id "tv_error" и visibility gone.
Второе, нам нужно создать в res-layout новый layout resource file pizza_item.xml. Этот layout нужен нам для того, чтобы показывать, как будут выглядеть элементы в данном списке. Как мы видим, этот layout содержит картинку(id item_img) с изображением пиццы, название(id item_title), описание(id item_description) и цену(id item_price). Думаю, сверстать эти два layout'а вам не составит труда. (с версткой и прочими вещами вы можете ознакомиться, перейдя по ссылке в начале статьи)
Теперь нам нужно в нашем проекте создать такие папки как: utils(здесь будут находиться вспомогательные классы), ui(все, что связанно пользовательским интерфейсом, то есть то, что видит наш пользователь), di(инъекция зависимостей), data(тут мы будем работать с данными). Внутри папки data создаём еще три папки local(локальные данные room) и remote(данные из сети) и entity(здесь хранятся наши модели, а конкретно data class Pizza, который содержит в себе id, title, descriprion, price, img, те данные, которые мы будем получать из сети и кэшировать в локальную базу данных). P.S.: из json api, который я использую для получения данных приходит Response, который содержит в себе переменную, которая хранит в себе список пицц val pizza: List<Pizza>, и если мы будем из сети делать запрос на data class Pizza, а не на Response, то данные не будут выгружены и Android студия выдаст нам ошибку, поэтому мы в дальнейшем мы будем mapper, который будет преобразовывать наш Response в List<Pizza> для корректной работы приложения. Об этом всём я расскажу далее.
Теперь вам нужно описать наши зависимости в build.gradle(:app) и в build.gradle(project) - все зависимости вы можете посмотреть перейдя по ссылке на github в начале статьи.
Начнём с вами с создания sealed class Resource и файла NetworkBoundResource в папке util, это шаблоны, которые помогут нам обрабатывать состояния и в зависимости от состояния, мы будем либо загружать данные из интернета, либо из кэша, либо выдавать ошибку пользователю, ну и так далее. Это стандартные шаблоны, которые вы можете применять в любом другом вашем приложении.
Папка util:
sealed class Resource:
sealed class Resource <T> (
val data : T ? = null,
val error : Throwable ?= null
){
class Success<T>(data: T) : Resource<T>(data)
class Loading<T>(data: T? = null) : Resource<T>(data)
class Error<T>(throwable: Throwable, data: T? = null) : Resource<T>(data, throwable)
}
файл NetworkBoundResource:
inline fun <ResultType, RequestType> networkBoundResource(
crossinline query: () -> Flow<ResultType>,
crossinline fetch: suspend () -> RequestType,
crossinline saveFetchResult: suspend (RequestType) -> Unit,
crossinline shouldFetch: (ResultType) -> Boolean = { true }
) = flow {
val data = query().first()
val flow = if (shouldFetch(data)) {
emit(Resource.Loading(data))
try {
saveFetchResult(fetch())
query().map { Resource.Success(it) }
} catch (throwable: Throwable) {
query().map { Resource.Error(throwable, it) }
}
} else {
query().map { Resource.Success(it) }
}
emitAll(flow)
}
Перейдем к папке data:
Кликаем правой кнопкой по папке entity и выбираем New -> Kotlin data class File from Json(туда мы вставляем наш json запрос, а именно: https://api.npoint.io/8aaa4c36d9afdd150144 , называем наш data class PizzaResponse, обратите внимание, что помимо PizzaResponse в папке entity будет создано еще 4 data class'а Pizza, Combo, Desert, Drink, мы в данном приложении работаем только с классом Pizza и PizzaResponse, поэтому вы можете удалить ненужные классы и в классе PizzaResponce удалить все из конструктора и оставить только val pizza: List<Pizza>.
В папке remote, где мы работаем с получением данных из сети, создаем interface PizzaApi:
interface PizzaService {
@GET(API_KEY)
suspend fun getPizzaList(): Response<PizzaResponse>
companion object{
const val API_KEY = "8aaa4c36d9afdd150144"
const val BASE_URL = "https://api.npoint.io/"
}
} P.S: константы на будущее лучше прописывать также в отдельном классе Const в папке util
Теперь, в папке entity нам нужно проставить в дата классе Pizza аннотаций для нашей базы данных Room:
@Entity(tableName = "pizza_table")
data class Pizza (
@PrimaryKey(autoGenerate = true)
val id: Int,
@ColumnInfo
val description: String,
val image: String,
val price: String,
val title: String
)
Далее в папке local создаём интерфейс PizzaDao, в котором прописываем методы для извлечения данных из локальной базы данных:
@Dao
interface PizzaDao {
@Query("SELECT * FROM pizza_table")
fun getAllPizza(): Flow<List<Pizza>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPizzas(pizza: List<Pizza>)
@Query("DELETE FROM pizza_table")
suspend fun deleteAllPizzas()
}
Переходим к созданию нашей базы данных. В папке local создаём абстрактный класс PizzaDataBase:
@Database(entities = [Pizza::class], version = 1, exportSchema = true)
abstract class PizzaDataBase: RoomDatabase() {
abstract fun getPizzaDao(): PizzaDao
}
Теперь нам нужно сделать инъекцию зависимостей и запровайдить наш Retrofit, Service и PizzaDataBase. Всё это мы будем делать в папке di:
Для начала создадим класс App, который будет наследоваться от класса Application() и пометим его аннотацией @HiltAndroidApp, это нужно для того, чтобы наш Hilt в приложении выполнял свою работу:
@HiltAndroidApp
class App: Application()
Далее нам нужно зарегистрировать этот класс в Манифесте, для этого в теге <application> укажем android:name=".di.App"
Не выходя из Манифеста также укажем разрешение на интернет и доступ к состоянию интернета над тегом <application>:
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
Далее в папке di создаём object AppModule, где мы будем провайдить наш service, retrofit и room:
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit = Retrofit.Builder()
.baseUrl(PizzaService.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
@Singleton
fun providePizzaService(retrofit: Retrofit): PizzaService =
retrofit.create(PizzaService::class.java)
@Provides
@Singleton
fun provideDataBase(@ApplicationContext context: Context): PizzaDataBase
=
Room.databaseBuilder(context, PizzaDataBase::class.java,
"pizza_database")
.build()
}
Отлично! Приступим к созданию class'a Repository, где мы будем реализовывать всю логику работы с данными и нашего кэширования:
class Repository @Inject constructor(
private val api: PizzaService,
private val db: PizzaDataBase
){
//Вы можете запровайдить в AppModul'е PizzaDao и передать его в
//конструктор
private val pizzaDao = db.getPizzaDao()
fun getPizzas() = networkBoundResource(
query = {
pizzaDao.getAllPizza()
},
fetch = {
delay(2000)
api.getPizzaList()
},
saveFetchResult = {
db.withTransaction {
pizzaDao.deleteAllPizzas()
pizzaDao.insertPizzas(mapResponseToList(it))
}
}
)
}
Помните я говорил про mapper, так вот в saveFetchResult pizzaDao.insertPizzas принимает в конструктор List<Pizza> , а нам прилетаем Response<PizzaResponse>, и если мы укажем в конструкторе it, то это приведет к ошибке, для этого нам нужно преобразовать Response<PizzaResponse> в List<Pizza>. Для этого создадим приватную функцию внутри нашего репозитория и вызовем её в конструкторе pizzaDao.insertPizzas и передадим в эту функцию it :
private fun mapResponseToList(entity: Response<PizzaResponse>): List<Pizza> {
return entity.body()!!.pizza
}
PS: Если в вашем случае из другого api не прилетает Response класс подобный, и в ApiService наследовались от List<Pizza>, а не от Response<PizzaResponse>, то никакой mapper вам бы не нужен был, просто бы передавали в insertPizzas it.
Осталось только поработать с папкой ui, в которой нам нужно создать класс MainAdapter, который реализует работу RecyclerView и класс MainViewModel(Наша ViewModel, более подробно об этом можете почитать здесь: https://developer.android.com/topic/libraries/architecture/viewmodel )
Начнём с MainViewModel, так как он будет максимально простой, ведь всю логику мы написали в нашем классе Repository:
@HiltViewModel
class MainViewModel @Inject constructor(repository: Repository): ViewModel() {
val pizzas = repository.getPizzas().asLiveData()
}
Перейдём к созданию класса MainAdaper, в котором я буду использовать DiffUtil.ItemCallback(о этом классе можно почитать тут: https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil.ItemCallback ):
class MainAdapter: ListAdapter<Pizza, MainAdapter.MainViewHolder>(MainDiffUtil()) {
inner class MainViewHolder(view: View): RecyclerView.ViewHolder(view)
class MainDiffUtil: DiffUtil.ItemCallback<Pizza>(){
override fun areItemsTheSame(oldItem: Pizza, newItem: Pizza): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Pizza, newItem: Pizza): Boolean {
return oldItem == newItem
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
return MainViewHolder(LayoutInflater.from(parent.context).inflate(
R.layout.pizza_item, parent, false)
)
}
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
holder.itemView.apply {
item_title.text = currentList[position].title
item_description.text = currentList[position].description
item_price.text = currentList[position].price
Glide.with(this)
.load(currentList[position].image)
.into(item_img)
}
}
}
Здесь я использовал библиотеку Glide для загрузки изображения из сети
Нам с вами осталось лишь реализовать нашу MainActivity, где нам нужно установить наш адаптер для recyclerview и нашу viewmodel, внутри viewmodel'и я использовал binding для доступа к элементам макета нашего экрана:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var _binding: ActivityMainBinding
private val binding: ActivityMainBinding get() = _binding
private val viewModel by viewModels<MainViewModel>()
lateinit var pizzaAdapter: MainAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
_binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
initAdapter()
initViewModel()
}
private fun initAdapter(){
pizzaAdapter = MainAdapter()
recyclerView.apply {
adapter = pizzaAdapter
layoutManager = LinearLayoutManager(this@MainActivity)
}
}
private fun initViewModel(){
viewModel.pizzas.observe(this@MainActivity){
pizzaAdapter.submitList(it.data)
progressBar.isVisible = it is Resource.Loading<*> && it.data.isNullOrEmpty()
tv_error.isVisible = it is Resource.Error<*> && it.data.isNullOrEmpty()
tv_error.text = it.error?.localizedMessage
}
}
}
Как видите кэширование это не только важно, но и очень даже не сложно. Вы можете скачать это приложение из моего репозитория на github и поиграться с ним.
Итого:
Мы получаем данный из сети и помещаем сразу же их в локальный кэш, после чего уже приложение не будет брать эти данные из сети, а будет брать их из своей локальной базы данных. Если кэша нет, и нет интернета, то пользователь получит сообщение об ошибке внутри приложения. Теперь наше приложение запускается и работает без интернета, так как берет все данный из своего локального хранилища.
Всем спасибо за прочтение, надеюсь было интересно и полезно!
Ссылки:
GitHub: https://github.com/evgenkr47/ExampleRoomCaching
Json API: https://api.npoint.io/8aaa4c36d9afdd150144
MVVM: https://developer.android.com/topic/libraries/architecture/viewmodel
DiffUtil.ItemCallback: https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil.ItemCallback