1 of 142

Алексей Михайлов

5 декабря 2020

MObile KOtlin

DEVELOPMENT

2 of 142

План

  • О себе и компании
  • Наши проекты с Kotlin Multiplatform Mobile
  • MOKO framework
    • Multiplatform UI

2

DEVELOPMENT

3 of 142

Не включено

Что такое Kotlin Multiplatform Mobile?

В выступлении Кати Петровой

В моем выступлении на AppsConf 2020

3

DEVELOPMENT

4 of 142

Немного о себе

IceRock Development

  • Разработка проектов на заказ
    • android, ios, web
  • 2+ года с Kotlin Multiplatform
  • 10+ проектов с Kotlin Multiplatform
  • Open Source contributors

Алексей Михайлов

  • 7 лет в разработке под мобилки
  • Разработка под iOS и Android
  • 3 года CTO
  • Продвигаю Kotlin Multiplatform в компании и за её пределами

4

DEVELOPMENT

5 of 142

Проекты

5

DEVELOPMENT

6 of 142

Наши проекты

  • Работа с камерой
  • Запрос разрешений для работы с камерой
  • Списки элементов
  • Формы заполнения данных

6

DEVELOPMENT

7 of 142

Наши проекты

  • Работа с камерой и галереей
  • Запись видео и сьемка фото интегрированные в UI
  • Запрос разрешений для работы с камерой
  • Формы заполнения данных
  • Списки и сетки элементов

7

DEVELOPMENT

8 of 142

Наши проекты

  • Работа с Bluetooth
  • Запрос разрешений для работы BT
  • Apple watchOS приложение ассистент
  • Все приложение на Kotlin
    • UI iOS тоже

8

DEVELOPMENT

9 of 142

Наши проекты

  • Работа геолокацией
  • Запрос разрешений для работы с геолокацией
  • Списки элементов
  • Формы заполнения данных
  • Постоянная отправка координат

9

DEVELOPMENT

10 of 142

Наши проекты

  • Работа геолокацией
  • Работа с картой
  • Запрос разрешений для работы с геолокацией
  • Списки элементов
  • Формы заполнения данных

10

DEVELOPMENT

11 of 142

Наши проекты

  • Работа геолокацией
  • Работа с картой
  • Запрос разрешений для работы с геолокацией
  • Списки элементов
  • Формы заполнения данных

11

DEVELOPMENT

12 of 142

MObile KOtlin

12

DEVELOPMENT

13 of 142

Упрощаем gradle

13

DEVELOPMENT

14 of 142

Упрощаем gradle

Для чего использовать?

Для упрощения настройки gradle mobile multiplatform проектов

и удобной интеграции CocoaPods

14

DEVELOPMENT

15 of 142

Упрощаем gradle

plugins {

id("com.android.library")

id("org.jetbrains.kotlin.multiplatform")

id("dev.icerock.mobile.multiplatform")

id("dev.icerock.mobile.multiplatform.ios-framework")

}

android {

compileSdkVersion(30)

defaultConfig {

minSdkVersion(24)

targetSdkVersion(30)

}

}

plugins {

id("com.android.library")

id("org.jetbrains.kotlin.multiplatform")

}

kotlin {

android()

ios()

}

android {

compileSdkVersion(30)

defaultConfig {

minSdkVersion(24)

targetSdkVersion(30)

}

sourceSets {

mapOf(

"main" to "src/androidMain",

...

).forEach { (name, root) ->

getByName(name).run {

setRoot(root)

}

}

}

}

val packForXcode by tasks.creating(Sync::class) {

group = "build"

val mode = System.getenv("CONFIGURATION") ?: "DEBUG"

val sdkName = System.getenv("SDK_NAME") ?: "iphonesimulator"

val targetName = "ios" + if (sdkName.startsWith("iphoneos")) "Arm64" else "X64"

val framework = kotlin.targets.getByName<KotlinNativeTarget>(targetName).binaries.getFramework(mode)

inputs.property("mode", mode)

dependsOn(framework.linkTask)

val targetDir = File(buildDir, "xcode-frameworks")

from({ framework.outputDirectory })

into(targetDir)

}

tasks.getByName("build").dependsOn(packForXcode)

15

DEVELOPMENT

16 of 142

Упрощаем gradle

16

DEVELOPMENT

17 of 142

Упрощаем gradle

1 - добавляем плагин ios-framework

2 - получаем gradle task’и sync***FrameworkIos***

3 - готовый фреймворк всегда по пути

build/cocoapods/framework

специально для podspec от CocoaPods

17

DEVELOPMENT

18 of 142

Упрощаем gradle

plugins {

id("dev.icerock.mobile.multiplatform.ios-framework")

}

// optional for export dependencies into framework header

framework {

export(project = project(":myproject"))

export(kotlinNativeExportable = MultiPlatfomLibrary(<...>))

export(kotlinNativeExportable = MultiPlatfomModule(<...>))

export(arm64Dependency = "***-iosarm64:***", x64Dependency = "***-iosx64:***")

}

18

DEVELOPMENT

19 of 142

Упрощаем gradle

cocoaPods {

podsProject = file("../ios-app/Pods/Pods.xcodeproj")

buildConfiguration = "dev-debug"

pod("MBProgressHUD")

pod(schema = "moko-widgets-flat", module = "mokoWidgetsFlat")

pod(schema = "moko-widgets-flat", module = "mokoWidgetsFlat", onlyLink = true)

}

19

DEVELOPMENT

20 of 142

Упрощаем gradle

cocoaPods {

podsProject = file("../ios-app/Pods/Pods.xcodeproj")

buildConfiguration = "dev-debug"

pod("MBProgressHUD")

pod(schema = "moko-widgets-flat", module = "mokoWidgetsFlat")

pod(schema = "moko-widgets-flat", module = "mokoWidgetsFlat", onlyLink = true)

}

20

DEVELOPMENT

21 of 142

Упрощаем gradle

cocoaPods {

podsProject = file("../ios-app/Pods/Pods.xcodeproj")

buildConfiguration = "dev-debug"

pod("MBProgressHUD")

pod(schema = "moko-widgets-flat", module = "mokoWidgetsFlat")

pod(schema = "moko-widgets-flat", module = "mokoWidgetsFlat", onlyLink = true)

}

21

DEVELOPMENT

22 of 142

Упрощаем gradle

cocoaPods {

podsProject = file("../ios-app/Pods/Pods.xcodeproj")

buildConfiguration = "dev-debug"

pod("MBProgressHUD")

pod(schema = "moko-widgets-flat", module = "mokoWidgetsFlat")

pod(schema = "moko-widgets-flat", module = "mokoWidgetsFlat", onlyLink = true)

}

задача компиляции CocoaPod

настройка линковки

интеграция cInterop в проект

22

DEVELOPMENT

23 of 142

Упрощаем gradle

cocoaPods {

podsProject = file("../ios-app/Pods/Pods.xcodeproj")

buildConfiguration = "dev-debug"

pod("MBProgressHUD")

pod(schema = "moko-widgets-flat", module = "mokoWidgetsFlat")

pod(schema = "moko-widgets-flat", module = "mokoWidgetsFlat", onlyLink = true)

}

задача компиляции CocoaPod

настройка линковки

интеграция cInterop в проект

23

DEVELOPMENT

24 of 142

Упрощаем gradle

cocoaPods {

podsProject = file("../ios-app/Pods/Pods.xcodeproj")

buildConfiguration = "dev-debug"

pod("MBProgressHUD")

pod(schema = "moko-widgets-flat", module = "mokoWidgetsFlat")

pod(schema = "moko-widgets-flat", module = "mokoWidgetsFlat", onlyLink = true)

}

задача компиляции CocoaPod

настройка линковки

интеграция cInterop в проект

24

DEVELOPMENT

25 of 142

Упрощаем gradle

  • Простая настройка сборки
  • Android исходники, включая AndroidManifest.xml, перемещены в androidMain
  • Интеграция с проектом через Cocoapods
    • поддержка и dynamic и static фреймворков
  • Расширенные возможности подключения cocoapod в kotlin для многомодульных проектов

25

DEVELOPMENT

26 of 142

Архитектурный подход

26

DEVELOPMENT

27 of 142

Архитектурный подход

Для чего использовать?

Для разработки по MVVM подходу

и легкой миграции с Android Architecture Components в common

27

DEVELOPMENT

28 of 142

Архитектурный подход

class MainViewModel : ViewModel() {

private val _text = MutableLiveData<String>()

val text: LiveData<String> = _text

init {

_text.value = "Hello ..."

viewModelScope.launch {

delay(1000)

_text.postValue("Hello World")

}

}

override fun onCleared() {

super.onCleared()

// do own cleanup here

}

}

import androidx.lifecycle.LiveData

import androidx.lifecycle.MutableLiveData

import androidx.lifecycle.ViewModel

import androidx.lifecycle.viewModelScope

import kotlinx.coroutines.delay

import kotlinx.coroutines.launch

android-arch-viewmodel

28

DEVELOPMENT

29 of 142

Архитектурный подход

class MainViewModel : ViewModel() {

private val _text = MutableLiveData<String>("")

val text: LiveData<String> = _text

init {

_text.value = "Hello ..."

viewModelScope.launch {

delay(1000)

_text.postValue("Hello World")

}

}

override fun onCleared() {

super.onCleared()

// do own cleanup here

}

}

import dev.icerock.moko.mvvm.livedata.LiveData

import dev.icerock.moko.mvvm.livedata.MutableLiveData

import dev.icerock.moko.mvvm.viewmodel.ViewModel

import kotlinx.coroutines.delay

import kotlinx.coroutines.launch

moko-mvvm

29

DEVELOPMENT

30 of 142

Архитектурный подход

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

setContentView(R.layout.activity_main)

val viewModel: MainViewModel = ViewModelProviders

.of(this).get(MainViewModel::class.java)

viewModel.text.observe(this) {

findViewById<TextView>(R.id.text).text = it

}

}

}

import android.os.Bundle

import android.widget.TextView

import androidx.appcompat.app.AppCompatActivity

import androidx.lifecycle.ViewModelProviders

android-arch-viewmodel

30

DEVELOPMENT

31 of 142

Архитектурный подход

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

setContentView(R.layout.activity_main)

val viewModel: MainViewModel = ViewModelProviders

.of(this).get(MainViewModel::class.java)

viewModel.text.ld().observe(this) {

findViewById<TextView>(R.id.text).text = it

}

}

}

import android.os.Bundle

import android.widget.TextView

import androidx.appcompat.app.AppCompatActivity

import androidx.lifecycle.ViewModelProviders

moko-mvvm - android

31

DEVELOPMENT

32 of 142

Архитектурный подход

class ViewController: UIViewController {

@IBOutlet var label: UILabel!

override func viewDidLoad() {

super.viewDidLoad()

let viewModel = MainViewModel()

viewModel.text.addObserver { [weak self] data in

self?.label.text = data as String?

}

}

}

import Foundation

import UIKit

import MultiPlatformLibrary

moko-mvvm - ios

32

DEVELOPMENT

33 of 142

Архитектурный подход

Замена SingleLiveEvent - commonMain

class MainViewModel(

val eventsDispatcher: EventsDispatcher<EventsListener>

) : ViewModel() {

init {

viewModelScope.launch {

delay(1000)

eventsDispatcher.dispatchEvent { showErrorDialog("error!") }

}

}

interface EventsListener {

fun showErrorDialog(message: String)

}

}

33

DEVELOPMENT

34 of 142

Архитектурный подход

Замена SingleLiveEvent - android app

override fun onCreate(savedInstanceState: Bundle?) {

...

val viewModel: MainViewModel = getViewModel {

MainViewModel(eventsDispatcher = eventsDispatcherOnMain())

}

viewModel.eventsDispatcher.bind(this, object : MainViewModel.EventsListener {

override fun showErrorDialog(message: String) {

println("show error dialog $message")

}

})

}

34

DEVELOPMENT

35 of 142

Архитектурный подход

Замена SingleLiveEvent - ios app

class ViewController: UIViewController {

@IBOutlet var label: UILabel!

override func viewDidLoad() {

super.viewDidLoad()

let viewModel = MainViewModel(eventsDispatcher: EventsDispatcher(listener: self))

}

}

extension ViewController: MainViewModelEventsListener {

func showErrorDialog(message: String) {

print("show error dialog \(message)")

}

}

35

DEVELOPMENT

36 of 142

Архитектурный подход

  • Настоящие AAC ViewModel, LiveData для android
  • Кастомная реализация ViewModel, LiveData для iOS
  • Null-safe типизация LiveData
  • Встроенные трансформации LiveData
  • Встроенная замена SingleLiveEvent с использованием interface
  • Поддержка coroutines
  • Поддержка databinding

36

DEVELOPMENT

37 of 142

Архитектурный подход

немного о планах

  • В ближайших планах разделить библиотеку на несколько модулей для возможности подключения только нужного - ViewModel, LiveData, databinding #65
  • Добавить поддержку ViewBinding из коробки для LiveData и StateFlow #76

Все пожелания можно оставлять на github moko-mvvm

37

DEVELOPMENT

38 of 142

Работаем с сетью

38

DEVELOPMENT

39 of 142

Работаем с сетью

Для чего использовать?

Для упрощения реализации REST API клиента на базе ktor-client

без необходимости писать все руками

39

DEVELOPMENT

40 of 142

Работаем с сетью

Retrofit

interface UserApi {

@GET("user")

fun getUserInfo(@Query("AppId") appId: String): Call<UserResponse>

}

40

DEVELOPMENT

41 of 142

Работаем с сетью

ktor-client

val httpClient = HttpClient()

viewModelScope.launch {

val response: HttpResponse = httpClient.get {

url("https://localhost/user")

parameter("appId", "123")

}

val text = response.readText()

}

41

DEVELOPMENT

42 of 142

Работаем с сетью

openapi: 3.0.0

info:

title: User server

version: v1

servers:

- url: http://localhost/

paths:

/user:

get:

parameters:

- name: appId

in: query

schema:

type: string

required: true

responses:

'200':

description: ok

content:

application/json:

schema:

$ref: '#/components/schemas/UserResponse'

components:

schemas:

UserResponse:

properties:

first_name:

type: string

last_name:

type: string

required:

- first_name

- last_name

type: object

42

DEVELOPMENT

43 of 142

Работаем с сетью

используя сгенерированный код

val defaultApi = DefaultApi(httpClient = HttpClient(), json = Json.Default)

viewModelScope.launch {

val user: UserResponse = defaultApi.userGet(appId = "123")

}

43

DEVELOPMENT

44 of 142

Работаем с сетью

  • Генерация REST API из OpenAPI спецификации
    • сущности (с kotlinx.serialization)
    • классы API (с ktor-client, coroutines и kotlinx.serialization)
  • Фичи для типовых сценариев
    • Токен авторизации и обновление токена
    • Централизованная обработка сетевых ошибок

44

DEVELOPMENT

45 of 142

Обращаемся к ресурсам

45

DEVELOPMENT

46 of 142

Обращаемся к ресурсам

Для чего использовать?

Использовать ресурсы приложения в бизнес-логике

и не дублировать ресурсы между платформами

46

DEVELOPMENT

47 of 142

Обращаемся к ресурсам

Пример (задача)

В зависимости от типа транспорта,

который мы получаем из данных сервера (enum),

нужно отобразить локализованную строку с названием этого транспорта

47

DEVELOPMENT

48 of 142

Обращаемся к ресурсам

Пример (задача)

enum class VehicleType {

BOAT,

CAR,

PLANE

}

class MainViewModel : ViewModel() {

val vehicleType: VehicleType

get() = ...

}

commonMain

48

DEVELOPMENT

49 of 142

Обращаемся к ресурсам

Пример (без moko-resources)

@StringRes

val text = when (viewModel.vehicleType) {

VehicleType.BOAT -> R.string.boatTitle

VehicleType.CAR -> R.string.carTitle

VehicleType.PLANE -> R.string.planeTitle

}

android-app

49

DEVELOPMENT

50 of 142

Обращаемся к ресурсам

Пример (без moko-resources)

let text: String

switch(viewModel.vehicleType) {

case VehicleType.boat:

text = NSLocalizedString("boatTitle", comment: "")

break

case VehicleType.car:

text = NSLocalizedString("carTitle", comment: "")

break

case VehicleType.plane:

text = NSLocalizedString("planeTitle", comment: "")

break

}

ios-app

50

DEVELOPMENT

51 of 142

Обращаемся к ресурсам

Пример (с moko-resources)

class MainViewModel : ViewModel() {

private val vehicleType: VehicleType get() = ...

val vehicleTypeString: StringResource

get() = when(vehicleType) {

VehicleType.BOAT -> MR.strings.boatTitle

VehicleType.CAR -> MR.strings.carTitle

VehicleType.PLANE -> MR.strings.planeTitle

}

}

commonMain

51

DEVELOPMENT

52 of 142

Обращаемся к ресурсам

Пример (с moko-resources)

@StringRes

val text = viewModel.vehicleTypeString.resourceId

android-app

52

DEVELOPMENT

53 of 142

Обращаемся к ресурсам

Пример (с moko-resources)

let resourceId = viewModel.vehicleTypeString.resourceId

let bundle = viewModel.vehicleTypeString.bundle

let text = NSLocalizedString(resourceId, bundle: bundle, comment: "")

ios-app

53

DEVELOPMENT

54 of 142

Обращаемся к ресурсам

Пример (с moko-resources)

<?xml version="1.0" encoding="UTF-8" ?>

<resources>

<string name="carTitle">Car</string>

<string name="boatTitle">Boat</string>

<string name="planeTitle">Plane</string>

</resources>

commonMain/resources

54

DEVELOPMENT

55 of 142

Обращаемся к ресурсам

Пример (с moko-resources)

expect object MR {

object strings : ResourceContainer<StringResource> {

val carTitle: StringResource

val boatTitle: StringResource

val planeTitle: StringResource

}

...

}

commonMain

55

DEVELOPMENT

56 of 142

Обращаемся к ресурсам

56

DEVELOPMENT

57 of 142

Обращаемся к ресурсам

StringDesc для разных источников

actual interface StringDesc {

fun toString(context: Context): String

}

androidMain

actual interface StringDesc {

fun localized(): String

}

iosMain

57

DEVELOPMENT

58 of 142

Обращаемся к ресурсам

StringDesc для разных источников

  • StringDesc.Resource(MR.strings.my_string)
  • StringDesc.ResourceFormatted(MR.strings.my_string_formatted, input)
  • StringDesc.Plural(MR.plurals.my_plural, quantity)
  • StringDesc.PluralFormatted(MR.plurals.my_plural, quantity, quantity)
  • StringDesc.Raw(user.name)

58

DEVELOPMENT

59 of 142

Обращаемся к ресурсам

StringDesc для разных источников

data class User(val fullName: String)

val user: User? get() { ... }

val title: StringDesc get() {

return if(user != null) StringDesc.Raw(user.fullName)

else StringDesc.Resource(MR.strings.not_authorized)

}

59

DEVELOPMENT

60 of 142

Обращаемся к ресурсам

  • Поддержка строк, числительных строк, изображений, файлов, шрифтов
  • Поддержка цветов с темной и светлой темой
  • Поддержка dynamic и static ios framework’ов
  • Специальный класс StringDesc для упрощения работы с получением строк
  • Поддержка смены языка в runtime

60

DEVELOPMENT

61 of 142

Получаем разрешения

61

DEVELOPMENT

62 of 142

Получаем разрешения

Для чего использовать?

Получать рантайм разрешения из общего кода

62

DEVELOPMENT

63 of 142

Получаем разрешения

class MyViewModel(val permissionsController: PermissionsController): ViewModel() {

fun onPhotoPressed() {

viewModelScope.launch {

try {

permissionsController.providePermission(Permission.GALLERY)

// Permission has been granted successfully.

} catch(deniedAlways: DeniedAlwaysException) {

// Permission is always denied.

} catch(denied: DeniedException) {

// Permission was denied.

}

}

}

}

63

DEVELOPMENT

64 of 142

Получаем разрешения

class MyViewModel(val permissionsController: PermissionsController): ViewModel() {

fun onPhotoPressed() {

viewModelScope.launch {

try {

permissionsController.providePermission(Permission.GALLERY)

// Permission has been granted successfully.

} catch(deniedAlways: DeniedAlwaysException) {

// Permission is always denied.

} catch(denied: DeniedException) {

// Permission was denied.

}

}

}

}

64

DEVELOPMENT

65 of 142

Получаем разрешения

class MyViewModel(val permissionsController: PermissionsController): ViewModel() {

fun onPhotoPressed() {

viewModelScope.launch {

try {

permissionsController.providePermission(Permission.GALLERY)

// Permission has been granted successfully.

} catch(deniedAlways: DeniedAlwaysException) {

// Permission is always denied.

} catch(denied: DeniedException) {

// Permission was denied.

}

}

}

}

65

DEVELOPMENT

66 of 142

Получаем разрешения

class MyViewModel(val permissionsController: PermissionsController): ViewModel() {

fun onPhotoPressed() {

viewModelScope.launch {

try {

permissionsController.providePermission(Permission.GALLERY)

// Permission has been granted successfully.

} catch(deniedAlways: DeniedAlwaysException) {

// Permission is always denied.

} catch(denied: DeniedException) {

// Permission was denied.

}

}

}

}

66

DEVELOPMENT

67 of 142

Получаем разрешения

class MyViewModel(val permissionsController: PermissionsController): ViewModel() {

fun onPhotoPressed() {

viewModelScope.launch {

try {

permissionsController.providePermission(Permission.GALLERY)

// Permission has been granted successfully.

} catch(deniedAlways: DeniedAlwaysException) {

// Permission is always denied.

} catch(denied: DeniedException) {

// Permission was denied.

}

}

}

}

67

DEVELOPMENT

68 of 142

Получаем разрешения

class MyViewModel(val permissionsController: PermissionsController): ViewModel() {

fun onPhotoPressed() {

viewModelScope.launch {

try {

permissionsController.providePermission(Permission.GALLERY)

// Permission has been granted successfully.

} catch(deniedAlways: DeniedAlwaysException) {

// Permission is always denied.

} catch(denied: DeniedException) {

// Permission was denied.

}

}

}

}

68

DEVELOPMENT

69 of 142

Получаем разрешения

override fun onCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

val viewModel = getViewModel {

// Pass the platform implementation of the permission controller to a common code.

MyViewModel(PermissionsController())

}

// Binds the permissions controller to the activity lifecycle.

viewModel.permissionsController.bind(lifecycle, supportFragmentManager)

}

android-app

69

DEVELOPMENT

70 of 142

Получаем разрешения

override fun onCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

val viewModel = getViewModel {

// Pass the platform implementation of the permission controller to a common code.

MyViewModel(PermissionsController())

}

// Binds the permissions controller to the activity lifecycle.

viewModel.permissionsController.bind(lifecycle, supportFragmentManager)

}

android-app

70

DEVELOPMENT

71 of 142

Получаем разрешения

// Just pass the platform implementation of the permission controller to a common code.

let viewModel = ViewModel(permissionsController: PermissionsController())

ios-app

71

DEVELOPMENT

72 of 142

Получаем разрешения

  • Реализация в соответствии с правилами платформ
    • Для android корректно обрабатывается смена конфигурации
  • Уже поддерживаются:
    • Permission.CAMERA
    • Permission.GALLERY
    • Permission.STORAGE
    • Permission.WRITE_STORAGE
    • Permission.LOCATION
    • Permission.COARSE_LOCATION
    • Permission.REMOTE_NOTIFICATION

72

DEVELOPMENT

73 of 142

Получаем медиа файлы

73

DEVELOPMENT

74 of 142

Получаем медиа файлы

Для чего использовать?

Получать изображения с камеры или галереи из общего кода

74

DEVELOPMENT

75 of 142

Получаем медиа файлы

class ImageSelectionViewModel(

val mediaPickerController: MediaPickerController

) : ViewModel() {

fun onSelectImagePressed() {

viewModelScope.launch {

try {

val image = mediaPickerController.pickImage(MediaSource.CAMERA)

// Use image

} catch (exc: Exception) {

// Some error

}

}

}

}

75

DEVELOPMENT

76 of 142

Получаем медиа файлы

class ImageSelectionViewModel(

val mediaPickerController: MediaPickerController

) : ViewModel() {

fun onSelectImagePressed() {

viewModelScope.launch {

try {

val image = mediaPickerController.pickImage(MediaSource.CAMERA)

// Use image

} catch (exc: Exception) {

// Some error

}

}

}

}

76

DEVELOPMENT

77 of 142

Получаем медиа файлы

class ImageSelectionViewModel(

val mediaPickerController: MediaPickerController

) : ViewModel() {

fun onSelectImagePressed() {

viewModelScope.launch {

try {

val image = mediaPickerController.pickImage(MediaSource.CAMERA)

// Use image

} catch (exc: Exception) {

// Some error

}

}

}

}

77

DEVELOPMENT

78 of 142

Получаем медиа файлы

class ImageSelectionViewModel(

val mediaPickerController: MediaPickerController

) : ViewModel() {

fun onSelectImagePressed() {

viewModelScope.launch {

try {

val image = mediaPickerController.pickImage(MediaSource.CAMERA)

// Use image

} catch (exc: Exception) {

// Some error

}

}

}

}

78

DEVELOPMENT

79 of 142

Получаем медиа файлы

override fun onCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

val viewModel = getViewModel {

val permissionsController = PermissionsController(

applicationContext = applicationContext

)

val mediaPickerController = MediaPickerController(permissionsController)

ImageSelectionViewModel(mediaPickerController)

}

viewModel.mediaPickerController.bind(lifecycle, supportFragmentManager)

}

android-app

79

DEVELOPMENT

80 of 142

Получаем медиа файлы

override fun onCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

val viewModel = getViewModel {

val permissionsController = PermissionsController(

applicationContext = applicationContext

)

val mediaPickerController = MediaPickerController(permissionsController)

ImageSelectionViewModel(mediaPickerController)

}

viewModel.mediaPickerController.bind(lifecycle, supportFragmentManager)

}

android-app

80

DEVELOPMENT

81 of 142

Получаем медиа файлы

let permissionsController = PermissionsController()

let mediaPickerController = MediaPickerController(

permissionsController: permissionsController,

viewController: self

)

viewModel = ImageSelectionViewModel(

mediaPickerController: mediaPickerController

)

ios-app

81

DEVELOPMENT

82 of 142

Получаем медиа файлы

expect class MediaPlayerController {

fun prepare(pathSource: String, listener: MediaPlayerListener)

fun start()

fun pause()

fun stop()

fun isPlaying(): Boolean

fun release()

}

82

DEVELOPMENT

83 of 142

Получаем медиа файлы

  • Простой способ получения изображений
  • Учитываются особенности работы обеих платформ
  • Автоматическое получение необходимых разрешений
  • Добавлена возможность управлять проигрыванием видео

83

DEVELOPMENT

84 of 142

Получаем координаты

84

DEVELOPMENT

85 of 142

Получаем координаты

Для чего использовать?

Отслеживать изменения геопозиции из общего кода

85

DEVELOPMENT

86 of 142

Получаем координаты

class TrackerViewModel(

val locationTracker: LocationTracker

) : ViewModel() {

init {

viewModelScope.launch {

locationTracker.getLocationsFlow()

.distinctUntilChanged()

.collect { println("new location: $it") }

}

}

fun onStartPressed() {

viewModelScope.launch { locationTracker.startTracking() }

}

fun onStopPressed() {

locationTracker.stopTracking()

}

}

86

DEVELOPMENT

87 of 142

Получаем координаты

class TrackerViewModel(

val locationTracker: LocationTracker

) : ViewModel() {

init {

viewModelScope.launch {

locationTracker.getLocationsFlow()

.distinctUntilChanged()

.collect { println("new location: $it") }

}

}

fun onStartPressed() {

viewModelScope.launch { locationTracker.startTracking() }

}

fun onStopPressed() {

locationTracker.stopTracking()

}

}

87

DEVELOPMENT

88 of 142

Получаем координаты

class TrackerViewModel(

val locationTracker: LocationTracker

) : ViewModel() {

init {

viewModelScope.launch {

locationTracker.getLocationsFlow()

.distinctUntilChanged()

.collect { println("new location: $it") }

}

}

fun onStartPressed() {

viewModelScope.launch { locationTracker.startTracking() }

}

fun onStopPressed() {

locationTracker.stopTracking()

}

}

88

DEVELOPMENT

89 of 142

Получаем координаты

class TrackerViewModel(

val locationTracker: LocationTracker

) : ViewModel() {

init {

viewModelScope.launch {

locationTracker.getLocationsFlow()

.distinctUntilChanged()

.collect { println("new location: $it") }

}

}

fun onStartPressed() {

viewModelScope.launch { locationTracker.startTracking() }

}

fun onStopPressed() {

locationTracker.stopTracking()

}

}

89

DEVELOPMENT

90 of 142

Получаем координаты

class TrackerViewModel(

val locationTracker: LocationTracker

) : ViewModel() {

init {

viewModelScope.launch {

locationTracker.getLocationsFlow()

.distinctUntilChanged()

.collect { println("new location: $it") }

}

}

fun onStartPressed() {

viewModelScope.launch { locationTracker.startTracking() }

}

fun onStopPressed() {

locationTracker.stopTracking()

}

}

90

DEVELOPMENT

91 of 142

Получаем координаты

val viewModel = getViewModel {

val locationTracker = LocationTracker(

permissionsController = PermissionsController(applicationContext)

)

TrackerViewModel(locationTracker)

}

viewModel.locationTracker.bind(lifecycle, this, supportFragmentManager)

android-app

91

DEVELOPMENT

92 of 142

Получаем координаты

let viewModel = TrackerViewModel(

locationTracker: LocationTracker(

permissionsController: PermissionsController(),

accuracy: kCLLocationAccuracyBest

)

)

ios-app

92

DEVELOPMENT

93 of 142

Получаем координаты

  • Простой способ отслеживания геолокации
  • Учитываются особенности работы обеих платформ
    • На android работает через Google Play Services
  • Автоматическое получение необходимых разрешений
  • Поддержка coroutines Flow

93

DEVELOPMENT

94 of 142

Управляем картами

94

DEVELOPMENT

95 of 142

Управляем картами

Для чего использовать?

Управлять картой из общего кода

95

DEVELOPMENT

96 of 142

Управляем картами

class MarkerViewModel(

val mapsController: GoogleMapController

) : ViewModel() {

fun start() {

viewModelScope.launch {

val marker1 = mapsController.addMarker(

image = MR.images.marker,

latLng = LatLng(

latitude = 55.045853,

longitude = 82.920154

),

rotation = 0.0f

) {

println("marker 1 pressed!")

}

marker1.rotation = 90.0f

}

}

}

96

DEVELOPMENT

97 of 142

Управляем картами

viewModelScope.launch {

val route = mapsController.buildRoute(

points = listOf(

LatLng(55.032200,82.889360),

LatLng(55.013109,82.926480)

),

lineColor = Color(0xCCCC00FF),

markersImage = MR.images.marker

)

}

97

DEVELOPMENT

98 of 142

Управляем картами

interface MapController {

fun showMyLocation(zoom: Float)

fun showLocation(...)

suspend fun getMapCenterLatLng(): LatLng

suspend fun addMarker(...): Marker

suspend fun buildRoute(...): MapElement

suspend fun drawPolygon(...): MapElement

suspend fun getAddressByLatLng(...): String?

suspend fun getSimilarNearAddresses(...): List<MapAddress>

suspend fun getCurrentZoom(): Float

suspend fun setCurrentZoom(zoom: Float)

suspend fun getZoomConfig(): ZoomConfig

suspend fun setZoomConfig(config: ZoomConfig)

}

98

DEVELOPMENT

99 of 142

Управляем картами

  • Перемещение камеры, изменение масштаба
  • Установка маркеров
  • Рисование путей
  • Рисование зон
  • Поддержка GoogleMaps, MapBox
    • возможность добавления новых провайдеров карт

99

DEVELOPMENT

100 of 142

Строим списки в UI

100

DEVELOPMENT

101 of 142

Строим списки в UI

Для чего использовать?

Для управления контентом RecyclerView/UITableView/UICollectionView из общего кода

101

DEVELOPMENT

102 of 142

Строим списки в UI

interface UnitFactory {

fun createHeader(text: String): TableUnitItem

fun createProfileTile(profileId: Long, username: String): TableUnitItem

}

102

DEVELOPMENT

103 of 142

Строим списки в UI

class ViewModel(unitFactory: UnitFactory) {

val items: List<TableUnitItem> = listOf(

unitFactory.createHeader("Programmers"),

unitFactory.createProfileTile(1, "Mikhailov"),

unitFactory.createProfileTile(2, "Babenko"),

unitFactory.createProfileTile(3, "Tchernov"),

unitFactory.createHeader("Designers"),

unitFactory.createProfileTile(4, "Eugeny")

)

}

103

DEVELOPMENT

104 of 142

Строим списки в UI

<androidx.recyclerview.widget.RecyclerView

android:layout_width="match_parent"

android:layout_height="match_parent"

app:bindValue="@{viewModel.items}"

app:adapter="@{`dev.icerock.moko.units.adapter.UnitsRecyclerViewAdapter`}"

app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

android-app

104

DEVELOPMENT

105 of 142

Строим списки в UI

let viewModel = ViewModel(unitFactory: UnitFactoryImpl())

let dataSource = TableUnitsSourceKt.default(for: tableView)

dataSource.unitItems = viewModel.items

ios-app

105

DEVELOPMENT

106 of 142

Строим списки в UI

object UnitFactoryImpl: UnitFactory {

fun createHeader(text: String): TableUnitItem {

return LayoutHeader()

.setText(text)

.setItemId(text.hashCode())

}

fun createProfileTile(profileId: Long, username: String): TableUnitItem {

return LayoutProfileTile()

.setUserName(username)

.setItemId(profileId)

}

}

android-app

106

DEVELOPMENT

107 of 142

Строим списки в UI

class UnitFactoryImpl: NSObject, UnitFactory {

func createHeader(text: String) -> TableUnitItem {

let data = HeaderTableViewCell.CellModel(text: text)

return UITableViewCellUnit<HeaderTableViewCell>(data: data, itemId: text.hashCode(), configurator: nil)

}

func createProfileTile(profileId: Long, username: String) -> TableUnitItem {

let data = ProfileTableViewCell.CellModel(username: username)

return UITableViewCellUnit<ProfileTableViewCell>(data: data, itemId: profileId, configurator: nil)

}

}

ios-app

107

DEVELOPMENT

108 of 142

Строим списки в UI

  • Полностью нативная верстка элементов
  • Декларативное управление списками и сетками для UI
  • Автоматические анимации с поиском разницы между текущим и новым контентом
  • Интеграция с databinding на android
    • Из верстки автоматически генерируется код для элементов
    • Интеграция опциональна - можно и вручную ViewHolder’ы делать
  • Есть moko-units-basic артефакт с несколькими юнитами готовыми из коробки

108

DEVELOPMENT

109 of 142

Шлем события по сокетам

109

DEVELOPMENT

110 of 142

Шлем события по сокетам

Для чего использовать?

Для передачи данных по Socket.IO протоколу

110

DEVELOPMENT

111 of 142

Шлем события по сокетам

val socket = Socket(

endpoint = "https://my-super-server:8080",

config = SocketOptions(

queryParams = mapOf("token" to "MySuperToken"),

transport = SocketOptions.Transport.WEBSOCKET

)

) {

on("employee.connected") { data ->

val serializer = DeliveryCar.serializer()

val json = JSON.nonstrict

val deliveryCar: DeliveryCar = json.parse(serializer, data)

//...

emit("car.placed", deliveryCar.name)

}

}

111

DEVELOPMENT

112 of 142

Шлем события по сокетам

val socket = Socket(

endpoint = "https://my-super-server:8080",

config = SocketOptions(

queryParams = mapOf("token" to "MySuperToken"),

transport = SocketOptions.Transport.WEBSOCKET

)

) {

on("employee.connected") { data ->

val serializer = DeliveryCar.serializer()

val json = JSON.nonstrict

val deliveryCar: DeliveryCar = json.parse(serializer, data)

//...

emit("car.placed", deliveryCar.name)

}

}

112

DEVELOPMENT

113 of 142

Шлем события по сокетам

val socket = Socket(

endpoint = "https://my-super-server:8080",

config = SocketOptions(

queryParams = mapOf("token" to "MySuperToken"),

transport = SocketOptions.Transport.WEBSOCKET

)

) {

on("employee.connected") { data ->

val serializer = DeliveryCar.serializer()

val json = JSON.nonstrict

val deliveryCar: DeliveryCar = json.parse(serializer, data)

//...

emit("car.placed", deliveryCar.name)

}

}

113

DEVELOPMENT

114 of 142

Шлем события по сокетам

val socket = Socket(

endpoint = "https://my-super-server:8080",

config = SocketOptions(

queryParams = mapOf("token" to "MySuperToken"),

transport = SocketOptions.Transport.WEBSOCKET

)

) {

on("employee.connected") { data ->

val serializer = DeliveryCar.serializer()

val json = JSON.nonstrict

val deliveryCar: DeliveryCar = json.parse(serializer, data)

//...

emit("car.placed", deliveryCar.name)

}

}

114

DEVELOPMENT

115 of 142

Шлем события по сокетам

  • Поддержка всех встроенных событий Socket.IO
  • Возможность подписаться на собственные события
  • Возможность отправлять события
  • Возможность передать query параметры
  • Пока данные в API только строки
    • этого достаточно для общения json’ами

115

DEVELOPMENT

116 of 142

Оповещаем об ошибках

116

DEVELOPMENT

117 of 142

Оповещаем об ошибках

Для чего использовать?

Для удобного и простого отображения ошибок пользователю

117

DEVELOPMENT

118 of 142

Оповещаем об ошибках

class SimpleViewModel(val exceptionHandler: ExceptionHandler) : ViewModel() {

fun onAlertButtonClick() {

viewModelScope.launch {

exceptionHandler.handle {

serverRequest()

}.catch<CustomException> {

println("Got CustomException!")

false

}.finally {

println("complete")

}.execute()

}

}

}

118

DEVELOPMENT

119 of 142

Оповещаем об ошибках

class SimpleViewModel(val exceptionHandler: ExceptionHandler) : ViewModel() {

fun onAlertButtonClick() {

viewModelScope.launch {

exceptionHandler.handle {

serverRequest()

}.catch<CustomException> {

println("Got CustomException!")

false

}.finally {

println("complete")

}.execute()

}

}

}

119

DEVELOPMENT

120 of 142

Оповещаем об ошибках

SimpleViewModel(

exceptionHandler = ExceptionHandler(

errorPresenter = AlertErrorPresenter(

alertTitle = MR.strings.alertTitle.desc(),

positiveButtonText = MR.strings.okButton.desc()

),

exceptionMapper = ExceptionMappersStorage.throwableMapper(),

onCatch = {

println("Got exception: $it")

}

)

)

120

DEVELOPMENT

121 of 142

Оповещаем об ошибках

SimpleViewModel(

exceptionHandler = ExceptionHandler(

errorPresenter = AlertErrorPresenter(

alertTitle = MR.strings.alertTitle.desc(),

positiveButtonText = MR.strings.okButton.desc()

),

exceptionMapper = ExceptionMappersStorage.throwableMapper(),

onCatch = {

println("Got exception: $it")

}

)

)

121

DEVELOPMENT

122 of 142

Оповещаем об ошибках

SimpleViewModel(

exceptionHandler = ExceptionHandler(

errorPresenter = AlertErrorPresenter(

alertTitle = MR.strings.alertTitle.desc(),

positiveButtonText = MR.strings.okButton.desc()

),

exceptionMapper = ExceptionMappersStorage.throwableMapper(),

onCatch = {

println("Got exception: $it")

}

)

)

122

DEVELOPMENT

123 of 142

Оповещаем об ошибках

SimpleViewModel(

exceptionHandler = ExceptionHandler(

errorPresenter = AlertErrorPresenter(

alertTitle = MR.strings.alertTitle.desc(),

positiveButtonText = MR.strings.okButton.desc()

),

exceptionMapper = ExceptionMappersStorage.throwableMapper(),

onCatch = {

println("Got exception: $it")

}

)

)

123

DEVELOPMENT

124 of 142

Оповещаем об ошибках

  • Не требуется передавать событие о необходимости показать ошибку на платформу
  • Контроль варианта UI отображения ошибки из общего кода
  • Встроенные варианты отображения ошибок:
    • AlertErrorPresenter
    • ToastErrorPresenter (для iOS - AlertErrorPresenter)
    • SnackbarErrorPresenter (для iOS - AlertErrorPresenter)
    • SelectorErrorPresenter (для динамического выбора презентера по конкретной ошибке)
  • Возможность создать свой ErrorPresenter

124

DEVELOPMENT

125 of 142

Анализируем ошибки

125

DEVELOPMENT

126 of 142

Анализируем ошибки

Для чего использовать?

Для отправки fatal/non-fatal ошибок с корректным стектрейсом

126

DEVELOPMENT

127 of 142

Анализируем ошибки

val logger = CrashlyticsLogger()

val antilog = CrashReportingAntilog(exceptionLogger = logger)

Napier.base(antilog = antilog)

127

DEVELOPMENT

128 of 142

Анализируем ошибки

Napier.i(message = "This is random message")

Napier.e(message = "create order failed", throwable = exception)

128

DEVELOPMENT

129 of 142

Общий код для UI

129

DEVELOPMENT

130 of 142

Общий код для UI

Для чего использовать?

Для разработки iOS и Android приложений

полностью из общего кода

130

DEVELOPMENT

131 of 142

Общий код для UI

131

DEVELOPMENT

132 of 142

Общий код для UI

132

DEVELOPMENT

133 of 142

Общий код для UI

133

DEVELOPMENT

134 of 142

Общий код для UI

134

DEVELOPMENT

135 of 142

Общий код для UI

  • Цель - 1 разработчик, а в результате 2 платформы с нативным UI/UX
  • Под капотом реализация схожа с React/Native - в общем коде некоторая абстракция реального UI элемента обеих платформ, а на самих платформах фабрики создания OEM элемента
  • Разработали 2 проекта на данной технологии внутри IceRock
    • Один из проектов доступен в Play Market, AppStore;
  • Текущей версии достаточно для реализации приложений с не сильно кастомизированным интерфейсом
  • Есть возможность встраивать полностью нативно реализованные экраны в приложение на виджетах, так же и наоборот

135

DEVELOPMENT

136 of 142

Общий код для UI

  • API не стабилизировано - возможны изменения
  • Разработка и доработка под себя осложнена из-за долгой компиляции K/N
    • Будем решать выносом всего UI кода в swift dynamic framework, который будет подключен к Kotlin
  • Новый UI инструментарий - новые проблемы
    • статическая типизация дает быстрый фидбек, без необходимости компиляции, но диктует свои правила по написанию кода, пробросу зависимостей и требует больше кода чем было бы с Any
    • в деталях реализации схожих UI элементов iOS и android возникают неочевидные нестыковки, которые нужно решать в общем API так, чтобы был прогнозируемый и одинаковый результат

136

DEVELOPMENT

137 of 142

Общий код для UI

    • С UI местами проще написать platformSpecific(android = …, ios = …) чем создавать expect/actual декларацию (например цвета, отступы)
    • Нет превью, требуется компиляция приложения и запуск для фидбека. На android это терпимо, но на iOS скорость компиляции все портит
    • Жизненный цикл android компонентов доставляет боль
  • Библиотека позволяет создать полноценное приложение - проверено

137

DEVELOPMENT

138 of 142

138

DEVELOPMENT

139 of 142

139

DEVELOPMENT

140 of 142

Ссылки

Спасибо за внимание

140

DEVELOPMENT

141 of 142

moko-units

<layout xmlns:android="http://schemas.android.com/apk/res/android">

<data>

<variable name="text" type="String" />

</data>

<TextView

android:layout_width="match_parent"

android:layout_height="wrap_content"

android:text="@{text}" />

</layout>

android-app (with databinding)

141

DEVELOPMENT

142 of 142

moko-units

actual class BasicTableUnitItem actual constructor(

override val itemId: Long, private val title: String

) : TableUnitItem {

override val viewType: Int = R.layout.basic_table_unit

override fun bindViewHolder(viewHolder: RecyclerView.ViewHolder) {

(viewHolder as ViewHolder).title.text = title

}

override fun createViewHolder(...): RecyclerView.ViewHolder {...}

private class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {

val title: TextView = view.findViewById(R.id.title)

}

}

android-app (without databinding)

142

DEVELOPMENT