Найти тему

Локальное кэширование. Local caching Room + Retrofit

Сегодняшнюю статью я посвящу кэшированию в андроид. Для того, чтобы понять, что тут происходит, вам нужно знать, что такое MVVM, Room, Retrofit, DI(Hilt), Coroutines и Lifecycle.

-> Предоставлю сразу ссылку на проект, с которым сегодня мы будем работать: https://github.com/evgenkr47/ExampleRoomCaching <-

P.S: в приложении не используется Clean Architecture, так как это займет намного больше времени, о чистой архитектуре мы поговорим с вами в отдельной статье.

Какие задачи решает локальное кэширование:

Мы с вами получаем какие-то данные из интернета, например список продуктов, используя API. Эти данные будут загружаться в локальную базу данных при помощи базы данных Room. Данная практика даёт нам прекрасные возможности, а именно:

1. Мы не будем каждый раз обращаться к нашему API за загрузкой данных, а будем моментально отображать наши данные из кэша. Во первых, на загрузку данных нужно определенное время, и пользователю будет не очень приятно каждый раз ожидать, когда же данные загрузятся(даже если загрузка занимает пару секунд, согласитесь, все равно не очень приятно). Во вторых, у пользователя могут быть проблемы с интернетом, например, плохое соединение, что в свою очередь сделает загрузку еще более долгой.

2. Важный момент - это работа приложения без интернета, если мы будем просто получать какие-то данные из интернета не кэшируя их локально, после чего отключим интернет и попытаемся перезапустить приложение, то приложение не запустится и упадет с ошибкой.

Надеюсь вы согласны, что отсутствие кэширования может вызвать раздражение у пользователя и последующее удаление вашего приложения с телефона.

Как выглядит схема алгоритма кэширования:

Теперь перейдём к конкретному примеру, у нас есть простое приложение, которое выгружает в recyclerview список продуктов(пицца):

-2

Первое с чего мы начнем это с создание макета 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