Данное руководство является продолжением в серии GiphyApp, перед началом требуется выполнить GiphyApp #1.
Готовый код проекта доступен на github.
Нужно чтобы приложение получало список Gif с сервиса GIPHY. В шаблоне сделан пример получения списка новостей с newsapi, реализовано это с использованием moko-network, который генерирует сетевые сущности и API классы из OpenAPI спецификации.
Имея OpenAPI спецификацию от GIPHY взятую с apis.guru можно заменить получение новостей на получение Gif.
Positive : Фича списка уже присутствует в шаблоне, поэтому логику не придется реализовывать. Для большего понимания как устроена фича следует ознакомиться с схемой модуля и посмотреть код в mpp-library:feature:list
.
Заменим содержимое файла mpp-library/domain/src/openapi.yml
содержимым из OpenAPI спецификации сервиса GIPHY. После этого можно вызвать Gradle Sync
и по завершению мы увидим что появились ошибки в коде, который работал с newsapi
. Нужно обновить этот код под новую API.
Positive : Сгенерированные файлы находятся по пути mpp-library/domain/build/generate-resources/main/src/main/kotlin
После замены OpenAPI спецификации в domain
модуле требуется обновить следующие классы:
News
– он должен быть заменен на Gif
;NewsRepository
– поправить под GifRepository
;DomainFactory
– добавить gifRepository
и предоставить ему нужные зависимости.News
преобразуем в следующий класс:
@Parcelize
data class Gif(
val id: Int,
val previewUrl: String,
val sourceUrl: String
) : Parcelable
Наша доменная сущность содержит id
гифки, нужный для корректного определения элемента в списке и корректных анимаций на UI, а также два варианта URL - полноразмерный вариант и превью.
К классу Gif
добавим преобразование из сетевой сущности dev.icerock.moko.network.generated.models.Gif
в доменную. Для этого добавим дополнительный конструктор:
@Parcelize
data class Gif(
...
) : Parcelable {
internal constructor(entity: dev.icerock.moko.network.generated.models.Gif) : this(
id = entity.url.hashCode(),
previewUrl = requireNotNull(entity.images?.downsizedMedium?.url) { "api can't respond without preview image" },
gifUrl = requireNotNull(entity.images?.original?.url) { "api can't respond without original image" }
)
}
В конструкторе происходит маппинг полей из сетевой сущности в доменную, что позволяет уменьшить количество необходимых изменений при изменении API. Само приложение становится независимым от деталей реализации API.
NewsRepository
превратим в GifRepository
с следующим контентом:
class GifRepository internal constructor(
private val gifsApi: GifsApi
) {
suspend fun getGifList(query: String): List<Gif> {
return gifsApi.searchGifs(
q = query,
limit = null,
offset = null,
rating = null,
lang = null
).data?.map { Gif(entity = it) }.orEmpty()
}
}
В данном репозитории нам достаточно получить GifsApi
(генерируется moko-network
) и вызвать метод API searchGifs
, где на данный момент используем только поисковой запрос, остальные аргументы оставив по умолчанию. Сетевые сущности сразу преобразуем в доменные, которые можем выдать наружу модуля (сетевые сущности генерируются с модификатором internal
).
В DomainFactory
нужно заменить создание newsApi
и newsRepository
, заменим их на следующий код:
private val gifsApi: GifsApi by lazy {
GifsApi(
basePath = baseUrl,
httpClient = httpClient,
json = json
)
}
val gifRepository: GifRepository by lazy {
GifRepository(
gifsApi = gifsApi
)
}
GifsApi
это сгенерированный класс, для создания требуется baseUrl
(адрес сервера с которым работаем, передается он через фабрику с нативного уровня, для возможности конфигурирования разных окружений сборки на обеих платформах), httpClient
(клиент для работы с сервером, от библиотеки ktor-client), json
(сериализатор Json от библиотеки kotlinx.serialization). API доступно только внутри модуля, предоставляется как зависимость в репозитории.
GifRepository
доступен вне модуля, для создания требуется только gifsApi
.
Инициализация делается lazy
, это означает что и API и репозиторий являются синглтонами (объекты живы пока жива фабрика, а ее держит SharedFactory
, которая жива на все время жизни приложения).
Также для работы с GIPHY API требуется передавать Api Key. Для этого можем использовать TokenFeature
для ktor
. Она уже подключена, ее нужно только переконфигурировать в следующее:
install(TokenFeature) {
tokenHeaderName = "api_key"
tokenProvider = object : TokenFeature.TokenProvider {
override fun getToken(): String? = "o5tAxORWRXRxxgIvRthxWnsjEbA3vkjV"
}
}
В данной реализации к каждому запросу, который посылается через httpClient
будет добавлен хидер api_key: o5tAxORWRXRxxgIvRthxWnsjEbA3vkjV
(сам ключ от тестового приложения, если у вас он уперся в лимиты - можете создать свой в разделе управления GIPHY).
В SharedFactory
требуется изменить интерфейс создания элементов списка - NewsUnitsFactory
, а также заменить синглтон newsFactory
на gifsFactory
с конфигурацией под Gif
.
Интерфейс создания элементов списка должен быть заменен на:
interface GifsUnitsFactory {
fun createGifTile(
id: Long,
gifUrl: String
): UnitItem
}
То есть из общей логики будет выдаваться id
для корректного определения diff'а списка с анимированием обновления и gifUrl
в котором будет url для вывода анимации на UI.
Фабрика для фичи списка заменяется на следующую:
val gifsFactory: ListFactory<Gif> = ListFactory(
listSource = object : ListSource<Gif> {
override suspend fun getList(): List<Gif> {
return domainFactory.gifRepository.getGifList("test")
}
},
strings = object : ListViewModel.Strings {
override val unknownError: StringResource = MR.strings.unknown_error
},
unitsFactory = object : ListViewModel.UnitsFactory<Gif> {
override fun createTile(data: Gif): UnitItem {
return gifsUnitsFactory.createGifTile(
id = data.id.toLong(),
gifUrl = data.previewUrl
)
}
}
)
В фабрике указывается источник данных - listSource
в котором мы вызываем gifRepository
из модуля domain
. Пока что query
зафиксирован в значении test
, что будет изменено в будущих уроках. Также указывается strings
- строки локализации, которые внедряются в модуль feature:list
(данному модулю требуется только строка "неизвестная ошибка"). Последнее что требуется модулю - unitsFactory
, но сам модуль работает с фабрикой имеющей 1 метод - createTile(data: Gif)
, а для нативных сторон лучше иметь более конкретный метод создания элемента списка, чтобы каждое поле влияющее на UI определялось из общей логики. Поэтому делается вызов в gifsUnitsFactory.createGifTile
.
Последнее что нужно сделать - обновить конструктор SharedLibrary
до следующего:
class SharedFactory(
settings: Settings,
antilog: Antilog,
baseUrl: String,
gifsUnitsFactory: GifsUnitsFactory
)
То есть нужно чтобы нативки передали реализацию GifsUnitsFactory
.
Адрес сервера, с которым мы работаем, внедряется с уровня приложения в общую библиотеку, чтобы не тратить время на пересборку общей библиотеки когда просто сменили сервер с которым работаем.
В текущей конфигурации из шаблона проекта есть только одно окружение и url сервера один. Он задается в android-app/build.gradle.kts
- заменим его:
android {
...
defaultConfig {
...
val url = "https://api.giphy.com/v1/"
buildConfigField("String", "BASE_URL", "\"$url\"")
}
}
Для реализации отображения gif нам потребуется библиотека glide и для выставления соотношения сторон элементов списка 2:1 constraintLayout.
constraintLayout
уже объявлен в зависимостях шаблона, нужно только подключить его к android-app
, для этого добавим в android-app/build.gradle.kts
:
dependencies {
...
implementation(Deps.Libs.Android.constraintLayout.name)
}
А glide
требуется добавить в объявление зависимостей. Для этого в buildSrc/src/main/kotlin/Versions.kt
добавляем:
object Versions {
...
object Libs {
...
object Android {
...
const val glide = "4.10.0"
}
}
}
А в buildSrc/src/main/kotlin/Deps.kt
:
object Deps {
...
object Libs {
...
object Android {
...
val glide = AndroidLibrary(
name = "com.github.bumptech.glide:glide:${Versions.Libs.Android.glide}"
)
}
После этого можно добавить в android-app/build.gradle.kts
подключение зависимости:
dependencies {
...
implementation(Deps.Libs.Android.glide.name)
}
Для создания SharedFactory
теперь требуется gifsUnitsFactory
вместо newsUnitsFactory
. Чтобы предоставить эту зависимость преобразуем класс NewsUnitsFactory
в следующий:
class GifListUnitsFactory : SharedFactory.GifsUnitsFactory {
override fun createGifTile(id: Long, gifUrl: String): UnitItem {
TODO()
}
}
А в SharedFactory
будем передавать его:
AppComponent.factory = SharedFactory(
baseUrl = BuildConfig.BASE_URL,
settings = AndroidSettings(getSharedPreferences("app", Context.MODE_PRIVATE)),
antilog = DebugAntilog(),
gifsUnitsFactory = GifListUnitsFactory()
)
Интерфейс SharedFactory.GifsUnitsFactory
требует, чтобы мы создали UnitItem
из id
и gifUrl
. Сам интерфейс UnitItem
относится к библиотеке moko-units и реализации можно генерировать из DataBinding layout'ов.
Создадим android-app/src/main/res/layout/tile_gif.xml
с содержимым:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="gifUrl"
type="String" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<ImageView
android:layout_width="match_parent"
android:layout_height="0dp"
app:gifUrl="@{gifUrl}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="2:1"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
И после этого запустим Gradle Sync
– после этого автоматически будет сгенерирован класс TileGif
, который мы и используем в GifListUnitsFactory
:
class GifListUnitsFactory : SharedFactory.GifsUnitsFactory {
override fun createGifTile(id: Long, gifUrl: String): UnitItem {
return TileGif().apply {
itemId = id
this.gifUrl = gifUrl
}
}
}
В самом layout'е мы использовали нестандартный Binding Adapter - app:gifUrl
. Нужно его реализовать, для этого создадим файл android-app/src/main/java/org/example/app/BindingAdapters.kt
с содержимым:
package org.example.app
import android.widget.ImageView
import androidx.databinding.BindingAdapter
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.bumptech.glide.Glide
@BindingAdapter("gifUrl")
fun ImageView.bindGif(gifUrl: String?) {
if (gifUrl == null) {
this.setImageDrawable(null)
return
}
val circularProgressDrawable = CircularProgressDrawable(context).apply {
strokeWidth = 5f
centerRadius = 30f
start()
}
Glide.with(this)
.load(gifUrl)
.placeholder(circularProgressDrawable)
.error(android.R.drawable.stat_notify_error)
.into(this)
}
Это добавит нам возможность задавать gifUrl
для ImageView
из layout'а. Причем на время загрузки будет отображаться прогресс бар, а при ошибке отобразится иконка ошибки.
Остается только создать экран, который будет отображать данные из нашей общей логики. Создадим android-app/src/main/res/layout/activity_gif_list.xml
с содержимым:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="org.example.library.domain.entity.Gif"/>
<import type="org.example.library.feature.list.presentation.ListViewModel" />
<variable
name="viewModel"
type="ListViewModel<Gif>" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:visibleOrGone="@{viewModel.state.ld.isSuccess}">
<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:adapter="@{`dev.icerock.moko.units.adapter.UnitsRecyclerViewAdapter`}"
app:bindValue="@{viewModel.state.ld.dataValue}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:visibleOrGone="@{viewModel.state.ld.isLoading}" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:text="@string/no_data"
app:visibleOrGone="@{viewModel.state.ld.isEmpty}" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="16dp"
android:orientation="vertical"
app:visibleOrGone="@{viewModel.state.ld.isError}">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@{viewModel.state.ld.errorValue}" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:onClick="@{() -> viewModel.onRetryPressed()}"
android:text="@string/retry_btn" />
</LinearLayout>
</FrameLayout>
</layout>
Layout использует Data Binding и отображает одно из 4 состояний полученное от ListViewModel
. В состоянии данных отображается SwipeRefreshLayout
с RecyclerView
внутри, а RecyclerView
использует LinearLayoutManager
и UnitsRecyclerViewAdapter
для отрисовки UnitItem
‘ов полученных из UnitsFactory
.
Теперь создадим android-app/src/main/java/org/example/app/view/GifListActivity.kt
с содержимым:
class GifListActivity : MvvmActivity<ActivityGifListBinding, ListViewModel<Gif>>() {
override val layoutId: Int = R.layout.activity_gif_list
override val viewModelClass = ListViewModel::class.java as Class<ListViewModel<Gif>>
override val viewModelVariableId: Int = BR.viewModel
override fun viewModelFactory(): ViewModelProvider.Factory = createViewModelFactory {
AppComponent.factory.gifsFactory.createListViewModel()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
with(binding.refreshLayout) {
setOnRefreshListener {
viewModel.onRefresh { isRefreshing = false }
}
}
}
}
Мы получаем из фабрики gifsFactory
нашу ListViewModel
и она будет выставлена в поле viewModel
в layout'е activity_gif_list
.
Также для корректной работы SwipeRefreshLayout
кодом задаем setOnRefreshListener
и вызываем viewModel.onRefresh
, который сообщит в лямбду что обновление завершено и мы сможем выключить анимацию обновления.
Сделаем чтобы запускалось приложение сразу с GifListActivity
. Для этого в android-app/src/main/AndroidManifest.xml
добавим GifListActivity
, а другие уберем (они нам не нужны):
<application ...>
<activity android:name=".view.GifListActivity" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
Теперь можно удалить все лишние файлы из примера:
android-app/src/main/java/org/example/app/view/ConfigActivity.kt
android-app/src/main/java/org/example/app/view/NewsActivity.kt
android-app/src/main/res/layout/activity_news.xml
android-app/src/main/res/layout/tile_news.xml
Теперь можно запустить приложение на Android и увидеть список Gif.
Так же как и на android, адрес сервера, с которым работаем, внедряется с уровня приложения в общую библиотеку, чтобы не тратить время на пересборку общей библиотеки когда просто сменили сервер с которым работаем. Настройка на iOS делается в ios-app/src/AppDelegate.swift
:
AppComponent.factory = SharedFactory(
...
baseUrl: "https://api.giphy.com/v1/",
...
)
Для отображения gif нам потребуется SwiftyGif, для его подключения нужно добавить в ios-app/Podfile
зависимость:
target 'ios-app' do
...
pod 'SwiftyGif', '5.1.1'
end
и после этого выполнить команду pod install
в директории ios-app
.
Для создания SharedFactory
теперь требуется gifsUnitsFactory
вместо newsUnitsFactory
. Чтобы предоставить эту зависимость преобразуем класс NewsUnitsFactory
в следующий:
class GifsListUnitsFactory: SharedFactoryGifsUnitsFactory {
func createGifTile(id: Int64, gifUrl: String) -> UnitItem {
// TODO
}
}
А в SharedFactory
будем передавать его:
AppComponent.factory = SharedFactory(
settings: AppleSettings(delegate: UserDefaults.standard),
antilog: DebugAntilog(defaultTag: "MPP"),
baseUrl: "https://api.giphy.com/v1/",
gifsUnitsFactory: GifsListUnitsFactory()
)
Интерфейс SharedFactoryGifsUnitsFactory
требует, чтобы мы создали UnitItem
из id
и gifUrl
. Сам интерфейс UnitItem
относится к библиотеке moko-units и реализация требует создания xib с интерфейсом ячейки и специального класса ячейки.
Создадим ios-app/src/units/GifTableViewCell.swift
с содержимым:
import MultiPlatformLibraryUnits
import SwiftyGif
class GifTableViewCell: UITableViewCell, Fillable {
typealias DataType = CellModel
struct CellModel {
let id: Int64
let gifUrl: String
}
@IBOutlet private var gifImageView: UIImageView!
private var gifDownloadTask: URLSessionDataTask?
override func prepareForReuse() {
super.prepareForReuse()
gifDownloadTask?.cancel()
gifImageView.clear()
}
func fill(_ data: GifTableViewCell.CellModel) {
gifDownloadTask = gifImageView.setGifFromURL(URL(string: data.gifUrl)!)
}
func update(_ data: GifTableViewCell.CellModel) {
}
}
extension GifTableViewCell: Reusable {
static func reusableIdentifier() -> String {
return "GifTableViewCell"
}
static func xibName() -> String {
return "GifTableViewCell"
}
static func bundle() -> Bundle {
return Bundle.main
}
}
И нужно создать ios-app/src/units/GifTableViewCell.xib
с версткой ячейки. Итоговый результат выглядит так: У самой UITableViewCell
нужно указать класс GifTableViewCell
: А так же указать идентификатор для переиспользования:
Теперь в GifListUnitsFactory
можно написать реализацию создания UnitItem
:
class GifsListUnitsFactory: SharedFactoryGifsUnitsFactory {
func createGifTile(id: Int64, gifUrl: String) -> UnitItem {
return UITableViewCellUnit<GifTableViewCell>(
data: GifTableViewCell.CellModel(
id: id,
gifUrl: gifUrl
),
configurator: nil
)
}
}
Остается только создать экран, который будет отображать данные из нашей общей логики.
Создадим ios-app/src/view/GifListViewController.swift
с содержимым:
import MultiPlatformLibraryMvvm
import MultiPlatformLibraryUnits
class GifListViewController: UIViewController {
@IBOutlet private var tableView: UITableView!
@IBOutlet private var activityIndicator: UIActivityIndicatorView!
@IBOutlet private var emptyView: UIView!
@IBOutlet private var errorView: UIView!
@IBOutlet private var errorLabel: UILabel!
private var viewModel: ListViewModel<Gif>!
private var dataSource: FlatUnitTableViewDataSource!
private var refreshControl: UIRefreshControl!
override func viewDidLoad() {
super.viewDidLoad()
viewModel = AppComponent.factory.gifsFactory.createListViewModel()
// binding methods from https://github.com/icerockdev/moko-mvvm
activityIndicator.bindVisibility(liveData: viewModel.state.isLoadingState())
tableView.bindVisibility(liveData: viewModel.state.isSuccessState())
emptyView.bindVisibility(liveData: viewModel.state.isEmptyState())
errorView.bindVisibility(liveData: viewModel.state.isErrorState())
// in/out generics of Kotlin removed in swift, so we should map to valid class
let errorText: LiveData<StringDesc> = viewModel.state.error().map { $0 as? StringDesc } as! LiveData<StringDesc>
errorLabel.bindText(liveData: errorText)
// datasource from https://github.com/icerockdev/moko-units
dataSource = FlatUnitTableViewDataSource()
dataSource.setup(for: tableView)
// manual bind to livedata, see https://github.com/icerockdev/moko-mvvm
viewModel.state.data().addObserver { [weak self] itemsObject in
guard let items = itemsObject as? [UITableViewCellUnitProtocol] else { return }
self?.dataSource.units = items
self?.tableView.reloadData()
}
refreshControl = UIRefreshControl()
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(onRefresh), for: .valueChanged)
}
@IBAction func onRetryPressed() {
viewModel.onRetryPressed()
}
@objc func onRefresh() {
viewModel.onRefresh { [weak self] in
self?.refreshControl.endRefreshing()
}
}
}
И перепривяжем в MainStoryboard
NewsViewController
к GifListViewController
:
Чтобы приложение запускалось сразу с экрана гифок, нужно указать у Navigation Controller
rootViewController
связь с GifListViewController
:
Теперь можно удалить все лишнее:
ios-app/src/units/NewsTableViewCell.swift
ios-app/src/units/NewsTableViewCell.xib
ios-app/src/view/ConfigViewController.swift
ios-app/src/view/NewsViewController.swift
Теперь можно запустить приложение на iOS и увидеть список Gif.