Данное руководство является продолжением в серии 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.ktandroid-app/src/main/java/org/example/app/view/NewsActivity.ktandroid-app/src/main/res/layout/activity_news.xmlandroid-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.swiftios-app/src/units/NewsTableViewCell.xibios-app/src/view/ConfigViewController.swiftios-app/src/view/NewsViewController.swiftТеперь можно запустить приложение на iOS и увидеть список Gif.