
Всем доброго времени суток! С вами Анна Жаркова, ведущий разработчик компании 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
ссылка на оригинал статьи https://habr.com/ru/post/596497/
Добавить комментарий