Всем доброго времени суток! С вами Анна Жаркова, ведущий разработчик компании Usetech. Продолжаем говорить про Kotlin Multiplatform и работу с асинхронными функциями. В этой статье мы будем рассматривать, как можно удобно подключать Kotlin общий код на стороне iOS, используя возможности Swift. А именно, как работать с Combine Publishers и новым async/await.
Концепция Kotlin Multiplatform позволяет нам сделать код максимально общим, т.е вынести практически все в общую часть.
Если на стороне common, мы оперируем корутинами и suspend функциями:
suspend fun getNewsList():ContentResponse<NewsList>{
return networkClient.request(Request(url = NEWS_LIST))
}
То на стороне iOS проекта нативного благодаря поддержке interop Kotlin/Obj-C с версии Kotlin 1.4 suspend функции преобразуются в функции с completion handler:
- (void)getNewsListWithCompletionHandler:(void (^)(SharedContentResponse<SharedNewsList *> * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("getNewsList(completionHandler:)")));
//Swift
newsService.getNewsList(completionHandler: { response, error in
if let data = response.content?.articles {
//...
}
}
Далее мы можем в этом блоке либо вызвать вывод данных, либо выполнение какого-то следующего метода. Все стандартно и просто.
Однако, не все любят простой синтаксис completion handler. А еще мы прекрасно знаем, что если ими злоупотреблять, можно легко попасть в ситуацию callback hell и потерять читабельность и чистоту кода.
Также не стоит забывать, что в зависимости от поставленной задачи у нас могут быть не только обобщаемые методы. Код платформенных проектов у нас может быть не идентичен, поэтому нельзя исключать логики сугубо нативной, вызовы которой мы можем сочетать с вызовами логики из общего модуля. Что вполне логично. Поэтому вполне нормально, что мы решим применить здесь доступные подходы конкретной платформы.
Попробуем сделать наш Kotlin код совместимым с Combine Publishers. Для этого превратим вызов нашей suspend функции в AnyPublisher с использованием Future Deferred и Promise.
func getNewsList()-> AnyPublisher<[NewsItem], Error> {
return Deferred {
Future { promise in
self.getNewsList { response, error in
if let data = response?.content?.articles {
promise(.success(data))
}
if let error = response?.errorResponse {
promise(.failure(CustomError(error: error.message)))
}
if let error = error {
promise(.failure(error))
}
}
}
}.eraseToAnyPublisher()
Для удобства можно даже вынести вызов метода в extension сервиса, осуществляющего запрос:
extension NewsService {
func getNewsList()-> AnyPublisher<[NewsItem], Error> {
return Deferred {
Future { promise in
//...
}
}
}
Вызов в коде нашего ObservableObject (если мы говорим про SwiftUI) или другой части логики будет абсолютно таким же, как и в других случаях работы с Publishers:
func loadData() {
let _ = newsService.getNewsList().sink { result in
switch result {
case .failure(let error):
print(error.localizedDescription)
default:
break
}
} receiveValue: { data in
self.items = [NewsItem]()
self.items.append(contentsOf: data)
}.store(in: &store)
}
Что ж, пока это выглядит, как перенос решения для работы с Combine. Особого профита не чувствуется. Тем более, если представить, что нам придется обернуть в Publisher каждый метод, который мы будем вызывать на стороне iOS.
Надо как-то обобщить.
Можно попробовать сделать своеобразный менеджер задач на стороне общего KMM кода, но в большинстве случаев это банальный оверинженеринг. Попробуем пойти через Kotlin Flows, которые мы можем представить в общем виде как Flow<T>.
На стороне Kotlin Multiplatform работа с Flow выглядит так:
//ViewModel
val newsFlow = MutableStateFlow<NewsList?>(null)
fun loadData() {
scope.launch {
val result = newsService.getNewsList()
newsFlow.value = result.content
}
}
Для андроид получение данных c Flow идет нативно и просто, нам достаточно подписаться на событие:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.newsList.collect {
setupNews(it)
}
}
}
Условно просто. Для iOS это suspend функция, которая требует свои параметры, одним из которых является специальный коллектор Kotlinx_coroutines_coreFlowCollector:
typealias Collector = Kotlinx_coroutines_coreFlowCollector
class Observer: Collector {
let callback:(Any?) -> Void
init(callback: @escaping (Any?) -> Void) {
self.callback = callback
}
func emit(value: Any?, completionHandler: @escaping (KotlinUnit?, Error?) -> Void) {
callback(value)
completionHandler(KotlinUnit(), nil)
}
}
Поэтому подписка у нас будет выглядеть немного по-другому:
lazy var collector: Observer = {
let collector = Observer {value in
if let value = value as? NewsList {
let data = value.articles
self.processNews(data: data)
}
}
return collector
}()
lazy var newsViewModel: NewsViewModel = {
let newsViewModel = NewsViewModel()
newsViewModel.newsFlow.collect(collector: self.collector,
completionHandler: {_,_ in })
return newsViewModel
}()
Также можно упростить и обобщить вызов с помощью обертки, которая внутри будет работать со своим Coroutine scope:
class AnyFlow<T>(source: Flow<T>): Flow<T> by source {
fun collect(onEach: (T) -> Unit,
onCompletion: (cause: Throwable?) -> Unit): Cancellable {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
scope.launch {
try {
collect {
onEach(it)
}
onCompletion(null)
} catch (e: Throwable) {
onCompletion(e)
}
}
return object : Cancellable {
override fun cancel() {
scope.cancel()
}
}
}
}
fun <T> Flow<T>.wrapToAny(): AnyFlow<T> = AnyFlow(this)
Получим примерно такой код:
val newsFlow = MutableStateFlow<NewsList?>(null)
val flowNewsItem = newsFlow.wrapToAny()
newsViewModel.flowNewsItem.collect { data in
//...
} onCompletion: { error in
//...
}
Попробуем обернуть в Publisher и подключить в Combine код. Есть несколько способов сделать это.
Можно добавить статический метод для Publishers:
extension Publishers {
static func createPublisher<T>(
wrapper: AnyFlow<T>
) -> AnyPublisher<T?, Error> {
var job: shared.Cancellable? = nil
//Kotlinx_coroutines_coreJob? = nil
return Deferred {
Future { promise in
job = wrapper.collect(onEach: { value in
promise(.success(value))
}, onCompletion: { error in
promise(.failure(CustomError (error:error)))
})
}.handleEvents( receiveCancel:
{
job?.cancel()
})
}.eraseToAnyPublisher()
}
}
Или сделать специальную структуру-обертку для нашей обертки потока (спасибо John O'Reilly за идею):
public struct FlowPublisher<T: AnyObject>: Publisher {
public typealias Output = T
public typealias Failure = Never
private let wrapper: AnyFlow<Output>
public init(wrapper: AnyFlow<Output>) {
self.wrapper = wrapper
}
public func receive<S: Subscriber>(subscriber: S) where S.Input == Output, S.Failure == Failure {
let subscription = FlowSubscription(wrapper: wrapper,
subscriber: subscriber)
subscriber.receive(subscription: subscription)
}
final class FlowSubscription<S: Subscriber>: Subscription where S.Input == Output, S.Failure == Failure {
private var subscriber: S?
private var job: shared.Cancellable? = nil
private let wrapper: AnyFlow<Output>
init(wrapper: AnyFlow<Output>, subscriber: S) {
self.wrapper = wrapper
self.subscriber = subscriber
job = wrapper.collect(onEach: { data in
subscriber.receive(data!)
}, onCompletion: { error in
if let error = error {
debugPrint(error.description())
}
subscriber.receive(completion: .finished)
})
}
func cancel() {
subscriber = nil
job?.cancel()
}
func request(_ demand: Subscribers.Demand) {}
}
}
Кода много, но писать его один раз. А использовать мы можем вот так, используя sink оператор для сбора результата:
func loadData() {
let _ = FlowPublisher<NewsList>(wrapper: newsViewModel.flowNewsItem).sink { result in
//...
} receiveValue: { data in
//...
}.store(in: &store)
}
Подготовить Flow переменные на стороне common кода KMM проще и быстрее, чем писать просто Publishers для каждого suspend метода.
Более красивым и аккуратным является использование async/await, не зря мы ждали его так долго.
Обертку async/await вокруг suspended функции сделать весьма просто:
func loadNews() async-> Result<[NewsItem],Error> {
return await withCheckedContinuation{ continuation in
newsService.getNewsList(completionHandler: { response,
error in
if let news = response?.content?.articles {
continuation.resume(returning: .success(news))
}
if let error = response?.errorResponse {
continuation.resume(returning:
.failure(CustomError(error: error.message)))
}
if let error = error {
continuation.resume(returning:
.failure(CustomError(error: error.localizedDescription)))
}
})
}
}
Как и сам вызов:
@MainActor
func loadAndSetup() {
Task {
let newsResult = await loadNews()
//...
}
}
}
Для работы с потоками добавим общую функцию:
func requestAsync<T>(wrapper: AnyFlow<T>) async -> Result<T?,Error> {
return await withCheckedContinuation{ continuation in
wrapper.collect { result in
continuation.resume(returning: .success(result))
} onCompletion: { error in
continuation.resume(returning:
.failure(CustomError(error: error)))
}
}
}
//Вызов
@MainActor
func loadAndSetup() {
Task {
let result = await requestAsync(wrapper:
newsViewModel.flowNewsItem)
//... Магия какая-то
}
}
Готово. В итоге мы получили интересные решения для комбинации Kotlin Multiplatform и Swift кода на стороне iOS. Какое из них вы выберете, как модернизируете и/или оптимизируете, уже дело за вами)
Советую ознакомиться со следующими источниками:
https://johnoreilly.dev/posts/kotlinmultiplatform-swift-combine_publisher-flow/
https://betterprogramming.pub/using-kotlin-flow-in-swift-3e7b53f559b6
https://johnoreilly.dev/posts/swift_async_await_kotlin_coroutines/
И исходниками:
https://github.com/anioutkazharkova/kn_network_sample