Количество просмотров166
3 января 2022

Kotlin Multiplatform. Работаем с асинхронностью на стороне iOS. 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. Какое из них вы выберете, как модернизируете и/или оптимизируете, уже дело за вами)