Kotlin Multiplatform. Работаем с асинхронностью на стороне iOS. Publishers, async/await

от автора

Всем доброго времени суток! С вами Анна Жаркова, ведущий разработчик компании 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *