食物にコルーチンを使用し、夜は静かに眠る方法

コルーチンは、非同期コード実行のための強力なツールです。 これらは並行して動作し、互いに通信し、リソースをほとんど消費しません。 恐れることなく、コルーチンを生産に導入できるように思われます。 しかし、恐れがあり、彼らは干渉します。



Vladimir IvanovAppsConfに関するレポートは、悪魔はそれほどひどくはなく、コルーチンをすぐに使用できるという事実に関するものです。







講演者について :Vladimir Ivanov( dzigoro )は、 EPAMで7年の経験を持つ主要なAndroid開発者であり、ソリューションアーキテクチャ、React Native、iOS開発が好きで、 Google Cloud Architectの証明書も持っています



読んだものはすべて経験とさまざまな研究の成果物であるため、保証なしでそのままお使いください。

コルーチン、KotlinおよびRxJava



詳細については、コルチンの現在のステータスはリリースにあり、ベータ版を残しています。 Kotlin 1.3がリリースされ、コルーチンが安定していると宣言され、世界に平和がもたらされました。







最近、コルーチンを使用している人々に関するTwitterで調査を実施しました。





統計は幸せではありません。 RxJavaは 、開発者がよく使用するタスクには複雑すぎるツールだと思います。 コルーチンは、非同期操作の制御により適しています。



以前のレポートで、RxJavaからKotlinのコルーチンにリファクタリングする方法について話しましたので、これについて詳しくは説明しませんが、要点のみを思い出してください。



なぜコルーチンを使用するのですか?



RxJavaを使用する場合、通常の実装例は次のようになるためです。



interface ApiClientRx { fun login(auth: Authorization) : Single<GithubUser> fun getRepositories (reposUrl: String, auth: Authorization) : Single<List<GithubRepository>> } //RxJava 2 implementation
      
      





インターフェイスがあります。たとえば、GitHubクライアントを作成し、そのためのいくつかの操作を実行したいとします。



  1. ログインユーザー。

  2. GitHubリポジトリのリストを取得します。



どちらの場合も、関数はGitHubUserまたはGitHubRepositoryのリストという単一のビジネスオブジェクトを返します。



このインターフェイスの実装コードは次のとおりです。



 private fun attemptLoginRx () { showProgress(true) compositeDisposable.add(apiClient.login(auth) .flatMap { user -> apiClient.getRepositories(user.repos_url, auth) } .map { list -> list.map { it.full_name } } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doFinally { showProgress(false) } .subscribe( { list -> showRepositories(this, list) }, { error -> Log.e("TAG", "Failed to show repos", error) } )) }
      
      





-compositeDisposableを使用して、メモリリークが発生しないようにします。

-最初のメソッドへの呼び出しを追加します。

-便利な演算子、たとえばflatMapを使用してユーザーを取得します。

-リポジトリのリストを取得します。

-Boilerplateを作成して、適切なスレッドで実行されるようにします。

-すべての準備が整ったら、ログインしているユーザーのリポジトリのリストを表示します。



RxJavaコードの問題:





バージョン0.26より前のコルーチンと同じコードはどうなりますか?



0.26では、APIが変更されており、生産について話しているところです。 製品に0.26を適用することはできていませんが、現在作業中です。



コルーチンを使用すると、インターフェイスが大幅に変更されます 。 関数は、Singlesおよびその他のヘルパーオブジェクトを返すのを停止します。 彼らはすぐにビジネスオブジェクトを返します:GitHubUserとGitHubRepositoryのリスト。 GitHubUserおよびGitHubRepository関数には、 一時停止修飾子があります。 サスペンドはほとんど何もする必要がないため、これは良いことです。



 interface ApiClient { suspend fun login(auth: Authorization) : GithubUser suspend fun getRepositories (reposUrl: String, auth: Authorization) : List<GithubRepository> } //Base interface
      
      





このインターフェイスの実装をすでに使用しているコードを見ると、RxJavaと比較して大幅に変更されています。



 private fun attemptLogin () { launch(UI) { val auth = BasicAuthorization(login, pass) try { showProgress(true) val userlnfo = async { apiClient.login(auth) }.await() val repoUrl = userlnfo.repos_url val list = async { apiClient.getRepositories(repoUrl, auth) }.await() showRepositories( this, list.map { it -> it.full_name } ) } catch (e: RuntimeException) { showToast("Oops!") } finally { showProgress(false) } } }
      
      





- コルーチンビルダーasyncを呼び出し、応答を待ってuserlnfoを取得するメインアクションが実行されます。

-このオブジェクトのデータを使用します。

-別の非同期呼び出しを行い、 awaitを呼び出します。



すべてが非同期の作業が発生していないように見え、列にコマンドを書き込むだけで実行されます。 最後に、UIで行う必要のあることを行います。



コルーチンが優れているのはなぜですか?





横に2ステップ



少し脱線しましょう、まだ議論が必要なことがいくつかあります。



ステップ1. withContext vs launch / async



コルーチンビルダー非同期に加えて コルーチンビルダーwithContextがあります。



起動または非同期で新しいCoroutineコンテキストを作成しますが 、これは必ずしも必要ではありません。 アプリケーション全体で使用するCoroutineコンテキストがある場合、それを再作成する必要はありません。 既存のものを単純に再利用できます。 これを行うには、コルーチンビルダーwithContextが必要になります。 既存のコルーチンコンテキストを単に再利用します。 2〜3倍高速になりますが、今では原理的な問題ではありません。 正確な数値が興味深い場合、ベンチマークと詳細を含むstackoverflowの 質問があります。



一般的なルール:意味的に適合する場所でwithContextを使用することは間違いありません。 ただし、複数の画像やデータの断片など、並列読み込みが必要な場合は、async / awaitを選択します。



ステップ2.リファクタリング



本当に複雑なRxJavaチェーンをリファクタリングするとどうなりますか? 私は本番でこれに遭遇しました:



 observable1.getSubject().zipWith(observable2.getSubject(), (t1, t2) -> { // side effects return true; }).doOnError { // handle errors } .zipWith(observable3.getSubject(), (t3, t4) -> { // side effects return true; }).doOnComplete { // gather data } .subscribe()
      
      





イベントバスに別の何かを送信する各ジッパーに ジップ副作用を伴う、 パブリックサブジェクトの複雑なチェーンがありました。 少なくとも、タスクはイベントバスを取り除くことでした。 私は1日を過ごしましたが、問題を解決するためにコードをリファクタリングできませんでした。 すべてを捨てて、4時間でコルーチンのコードを書き直すという正しい決定が判明しました



以下のコードは私が手に入れたものと非常に似ています:



 try { val firstChunkJob = async { call1 } val secondChunkJob = async { call2 } val thirdChunkJob = async { call3 } return Result( firstChunkJob.await(), secondChunkJob.await(), thirdChunkJob.await()) } catch (e: Exception) { // handle errors }
      
      





-1つのタスク、2番目と3番目のタスクに対して非同期を実行します。

-結果を待って、すべてをオブジェクトに入れます。

-完了!



複雑なチェーンがあり、コルーチンがある場合は、リファクタリングするだけです。 本当に速いです。



開発者がprodでコルーチンを使用できないのはなぜですか?



私の意見では、開発者として、私たちは現在、何か新しいものを恐れるだけでコルーチンの使用を妨げられています。





これらの4つの不安を取り除くと、夜は静かに眠り、生産でコルーチンを使用できます。



ポイントごとにみましょう。



1.ライフサイクル管理





やめて



Thread.stop()に精通していますか? あなたがそれを使用した場合、その後は長くありません。 JDK 1.1では、特定のコードを取得および停止することは不可能であり、正しく完了する保証はないため、メソッドはすぐに廃止されたと宣言されました。 ほとんどの場合、 メモリ破損のみが発生します



したがって、 Thread.stop()は機能しません 。 キャンセルは協調的である必要があります。つまり、キャンセルすることを知るために反対側のコードが必要です。



RxJavaでストップを適用する方法:



 private val compositeDisposable = CompositeDisposable() fun requestSmth() { compositeDisposable.add( apiClientRx.requestSomething() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> {}) } override fun onDestroy() { compositeDisposable.dispose() }
      
      







RxJavaではCompositeDisposableを使用します



-フラグメントまたはプレゼンターのアクティビティに変数compositeDisposableを追加します。ここでは、RxJavaを使用します。

-onDestro yでDisposeを追加すると、すべての例外が自動的に消えます。



コルーチンとほぼ同じ原理:



 private val job: Job? = null fun requestSmth() { job = launch(UI) { val user = apiClient.requestSomething() … } } override fun onDestroy() { job?.cancel() }
      
      





簡単なタスクの例を考えてみましょう。



通常、 コルーチンビルダージョブを返し、場合によってはDeferredを返します。



-私たちはこの仕事を覚えることができます。

- 「launch」 コルーチンビルダーコマンドを指定します。 プロセスが開始され、何かが起こり、実行の結果が記憶されます。

-他に何も渡さない場合、「起動」は機能を開始し、ジョブへのリンクを返します。

-ジョブは記憶されており、onDestroyで「キャンセル」と言うと、すべてが正常に機能します。



アプローチの問題は何ですか? 各ジョブにはフィールドが必要です。 ジョブをまとめてキャンセルするには、ジョブのリストを維持する必要があります。 このアプローチはコードの重複を招きますが、そうしないでください。



良いニュースは、 CompositeJobLifecycle-aware jobという 代替手段があることです。



CompositeJobはcompositeDisposableの類似物です。 次のようになります



 private val job: CompositeJob = CompositeJob() fun requestSmth() { job.add(launch(UI) { val user = apiClient.requestSomething() ... }) } override fun onDestroy() { job.cancel() }
      
      





-1つのフラグメントに対して1つのジョブを開始します。

「すべてのジョブをCompositeJobに入れて、 「job.cancel()for everyone!」というコマンドを実行します。」



このアプローチは、クラス宣言をカウントせずに4行で簡単に実装できます。



 Class CompositeJob { private val map = hashMapOf<String, Job>() fun add(job: Job, key: String = job.hashCode().toString()) = map.put(key, job)?.cancel() fun cancel(key: String) = map[key]?.cancel() fun cancel() = map.forEach { _ ,u -> u.cancel() } }
      
      







次のものが必要です。



-文字列キーでマップし、

-ジョブを追加するメソッドを追加します。

-オプションのキーパラメータ。



同じジョブに同じキーを使用する場合は、お願いします。 そうでない場合、 hashCodeが問題を解決します。 渡したマップにジョブを追加し、同じキーで以前のジョブをキャンセルします。 タスクを過剰に実行した場合、以前の結果は興味がありません。 私たちはそれをキャンセルし、再び運転します。



キャンセルは簡単です。キーでジョブを取得してキャンセルします。 マップ全体の2回目のキャンセルでは、すべてがキャンセルされます。 すべてのコードは4行で30分で記述され、機能します。 書きたくない場合は、上の例をご覧ください。



ライフサイクル対応ジョブ



Androidライフサイクルライフサイクルの所有者、またはオブザーバーを使用しましたか?





アクティビティフラグメントには特定の状態があります。 ハイライト: 作成、 開始再開 。 状態にはさまざまな遷移があります。 LifecycleObserverを使用すると、これらの遷移にサブスクライブし、遷移の1つが発生したときに何かを実行できます。



それは非常に簡単に見えます:



 public class MyObserver implements LifecycleObserver { @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) public void connectListener() { ... } @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) public void disconnectListener() { … } }
      
      





メソッドのパラメーターを使用して注釈を切断すると、対応する遷移で呼び出されます。 コルーチンにこのアプローチを使用してください:



 class AndroidJob(lifecycle: Lifecycle) : Job by Job(), LifecycleObserver { init { lifecycle.addObserver(this) } @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) fun destroy() { Log.d("AndroidJob", "Cancelling a coroutine") cancel() } }
      
      





-AndroidJob基本クラスを作成できます。

- ライフサイクルをクラスに転送します。

-LifecycleObserverインターフェースがジョブを実装します。



必要なもの:



-コンストラクターで、オブザーバーとしてライフサイクルに追加します。

-ON_DESTROYまたはその他の興味のあるものを購読します。

-ON_DESTROYでキャンセルします。

-フラグメントで1つのparentJob取得します。

-コンストラクターのJoyジョブまたはアクティビティフラグメントのライフサイクルを呼び出します。 違いはありません。

-このparentJobparentとして渡します。



完成したコードは次のようになります。



 private var parentJob = AndroidJob(lifecycle) fun do() { job = launch(UI, parent = parentJob) { // code } }
      
      





親をキャンセルすると、すべての子コルーチンがキャンセルされ、フラグメントに何かを書く必要がなくなります。 すべてが自動的に行われ、ON_DESTROYは不要です。 主なものは、 parent = parentJobを渡すことを忘れないでください。



使用する場合、あなたはあなたを強調する単純なリント規則を書くことができます:「ああ、あなたはあなたの親を忘れました!」



  ライフサイクル管理が整理されました。 これを簡単かつ快適に行えるツールがいくつかあります。



本番環境での複雑なシナリオと重要なタスクについてはどうですか?



2.複雑なユースケース



複雑なシナリオと重要なタスクは次のとおりです。



- 演算子 -RxJavaの複雑な演算子:flatMap、デバウンスなど

- エラー処理-複雑なエラー処理。 try..catchだけでなく、たとえばネストされています。

- キャッシュ 重要なタスクです。 本番環境では、キャッシュに遭遇し、コルーチンを使用してキャッシュの問題を簡単に解決するツールを入手したいと考えました。



繰り返す



コルーチンの演算子を考えたとき、最初のオプションはrepeatWhen()でした。



何かがうまくいかず、Corutinが内部のサーバーに到達できなかった場合は、何らかの指数関数的なフォールバックで何度か再試行します。 おそらく、接続が不十分であるため、操作を数回繰り返すことで目的の結果を得ることができます。



コルーチンを使用すると、このタスクを簡単に実装できます。



 suspend fun <T> retryDeferredWithDelay( deferred: () -> Deferred<T>, tries: Int = 3, timeDelay: Long = 1000L ): T { for (i in 1..tries) { try { return deferred().await() } catch (e: Exception) { if (i < tries) delay(timeDelay) else throw e } } throw UnsupportedOperationException() }
      
      







オペレーターの実装:



-彼は据え置きを取ります。

-このオブジェクトを取得するには、 asyncを呼び出す必要があります。

-Deferredの代わりに、サスペンドブロックと一般的にサスペンド関数の両方を渡すことができます。

-forループ-コルーチンの結果を待っています。 何かが発生し、繰り返しカウンターが使い果たされていない場合は、 Delayを介して再試行してください。 そうでない場合は、いいえ。



関数は簡単にカスタマイズできます。指数関数的な遅延を設定するか、状況に応じて遅延を計算するラムダ関数を渡します。



それを使用して、それは動作します!



ジップ



また、それらに頻繁に遭遇します。 ここでも、すべてが簡単です。



 suspend fun <T1, T2, R> zip( source1: Deferred<T1>, source2: Deferred<T2>, zipper: BiFunction<T1, T2, R>): R { return zipper.apply(sourcel.await(), source2.await()) } suspend fun <T1, T2, R> Deferred<T1>.zipWith( other: Deferred<T2>, zipper: BiFunction<T1, T2, R>): R { return zip(this, other, zipper) }
      
      





- ジッパーを使用して、Deferredで待機します。

-Deferredの代わりに、withContextでsuspend関数とコルーチンビルダーを使用できます。 必要なコンテキストを伝えます。



これは再び機能し、この恐怖を取り除いてくれることを願っています。



キャッシュ





RxJavaを使用して実稼働環境でキャッシュを実装していますか? RxCacheを使用します。





左側の図: ViewおよびViewModel 。 右側にはデータソースがあります。ネットワークコールとデータベースです。



何かをキャッシュしたい場合、キャッシュはデータの別のソースになります。



キャッシュの種類:





3番目のケースでは、単純でプリミティブなキャッシュを作成します。 コルーチンビルダーwithContextが再び助けになります。



 launch(UI) { var data = withContext(dispatcher) { persistence.getData() } if (data == null) { data = withContext(dispatcher) { memory.getData() } if (data == null) { data = withContext(dispatcher) { network.getData() } memory.cache(url, data) persistence.cache(url, data) } } }
      
      





-withContextを使用して各操作を実行し、データが来ているかどうかを確認します。

- 永続性からのデータが来ない場合、あなたはmemory.cacheからそれを取得しようとしています。

-memory.cacheも存在しない場合は、 ネットワークソースに連絡してデータを取得します。 もちろん、すべてのキャッシュに入れることを忘れないでください。



これはかなり原始的な実装であり、多くの質問がありますが、1つの場所にキャッシュが必要な場合、この方法は機能します。 実稼働タスクの場合、このキャッシュは十分ではありません。 もっと複雑なものが必要です。



RxにはRxCacheがあります



まだRxJavaを使用している場合は、RxCacheを使用できます。 まだ使用しています。 RxCacheは特別なライブラリです。 データをキャッシュし、ライフサイクルを管理できます。



たとえば、このデータは15分後に有効期限が切れると言います。「この期間の後、キャッシュからデータを送信せずに、新しいデータを送信してください。」



ライブラリは、チームを宣言的にサポートするという点で素晴らしいです。 宣言は、 Retrofitで行うことと非常に似ています。



 public interface FeatureConfigCacheProvider { @ProviderKey("features") @LifeCache(duration = 15, timeUnit = TimeUnit.MINUTES) fun getFeatures( result: Observable<Features>, cacheName: DynamicKey ): Observable<Reply<Features>> }
      
      





-CacheProviderがあると言います。

-メソッドを開始し、 LifeCacheの有効期間は 15分であると言います。 それが利用可能になるキーは機能です。

-Observable <Replyを返します。Replyは、キャッシュを操作するための補助ライブラリオブジェクトです。



使い方はとても簡単です:



 val restObservable = configServiceRestApi.getFeatures() val features = featureConfigCacheProvider.getFeatures( restObservable, DynamicKey(CACHE_KEY) )
      
      





-RxキャッシュからRestApiにアクセスします。

-CacheProviderに切り替えます。

-彼にObservableを与えます。

-ライブラリ自体が何をすべきかを判断します。キャッシュに行くかどうか、時間がなくなった場合はObservableに切り替えて別の操作を実行します。



ライブラリの使用は非常に便利で、コルーチンについても同様のものを入手したいと思います。



開発中のコルーチンキャッシュ



EPAM内部では、RxCacheのすべての機能を実行するCoroutine Cacheライブラリを作成しています。 最初のバージョンを作成し、社内で実行しました。 最初のリリースが公開され次第、Twitterに投稿します。 次のようになります。



 val restFunction = configServiceRestApi.getFeatures() val features = withCache(CACHE_KEY) { restFunction() }
      
      





中断関数getFeaturesがあります。 関数をブロックとして特別な高次関数withCacheに渡します 。これにより 、何をする必要があるかがわかります。



おそらく、宣言関数をサポートするために同じインターフェースを作成するでしょう。



エラー処理







単純なエラー処理は多くの場合、開発者によって発見され、通常は非常に簡単に解決されます。 複雑なものがない場合は、catchで例外をキャッチし、そこで起こったことを確認し、ログに書き込み、エラーをユーザーに表示します。 UIでは、これを簡単に行うことができます。



単純な場合、すべてが単純であることが期待されます-コルーチンを使用したエラー処理はtry-catch-finallyを介して行われます。



本番環境では、単純な場合に加えて、次のものがあります。



-ネストされたtry-catch

-さまざまな種類の例外

-ネットワークまたはビジネスロジックのエラー、

-ユーザーエラー。 彼は再び何か間違ったことをし、すべてのせいにすることでした。



このために準備する必要があります。



2つの解決策があります。CoroutineExceptionHandlerResultクラスのアプローチです。



コルーチン例外ハンドラー



これは、エラーの複雑なケースを処理するための特別なクラスです。 ExceptionHandlerを使用すると、 例外を引数としてエラーとして受け取り、それを処理できます。



通常、複雑なエラーをどのように処理しますか?



ユーザーが何かを押したが、ボタンが機能しなかった。 彼は何がうまくいかなかったかを言い、それを特定のアクションに向ける必要があります。インターネット、Wi-Fiをチェックし、後で試すか、アプリケーションを削除して、二度と使用しないでください。 これをユーザーに言うのは非常に簡単です:



 val handler = CoroutineExceptionHandler(handler = { , error -> hideProgressDialog() val defaultErrorMsg = "Something went wrong" val errorMsg = when (error) { is ConnectionException -> userFriendlyErrorMessage(error, defaultErrorMsg) is HttpResponseException -> userFriendlyErrorMessage(Endpoint.EndpointType.ENDPOINT_SYNCPLICITY, error) is EncodingException -> "Failed to decode data, please try again" else -> defaultErrorMsg } Toast.makeText(context, errorMsg, Toast.LENGTH_SHORT).show() })
      
      





-デフォルトメッセージ:「何かがおかしい!」を開き、例外を分析します。

-これがConnectionExceptionである場合、リソースからローカライズされたメッセージを受け取ります。「人、Wi-Fiをオンにすると、問題はなくなります。 保証します。」

- サーバーが何か間違っていると言った場合、クライアントに「ログアウトして再度ログインする」、「モスクワでこれを行わない、他の国でこれを行う」、または「申し訳ありませんが、同志。 私にできることは、単に何かがうまくいかなかったと言うだけです。」

-これが完全に異なる間違いである場合、たとえばメモリ不足の場合、「何か問題が発生しました。ごめんなさい」と言います。

-すべてのメッセージが表示されます。



CoroutineExceptionHandlerに書き込むものは、 コルーチンを実行するのと同じDispatcherで実行されます。 したがって、「起動」UIコマンドを指定すると、すべてがUIで実行されます。 個別のディスパッチは必要ありません。これは非常に便利です。



使い方は簡単です:



 launch(uiDispatcher + handler) { ... }
      
      





プラス演算子があります。 コルーチンコンテキストでハンドラーを追加すると、すべてが機能します。これは非常に便利です。 これをしばらく使用しました。



結果クラス



後で、CoroutineExceptionHandlerが欠落している可能性があることに気付きました。 コルーチンの作業によって形成される結果は、異なる部分からの複数のデータで構成されたり、複数の状況を処理したりできます。



結果クラスのアプローチは、この問題に対処するのに役立ちます。



 sealed class Result { data class Success(val payload: String) : Result() data class Error(val exception: Exception) : Result() }
      
      





-ビジネスロジックで、 Resultクラスを開始します。

- 封印済みとしてマークします。

-クラスから、他の2つのデータクラスを継承します: SuccessErrorです。

- 成功では、コルーチン実行の結果として生成されたデータを転送します。

- エラーで例外を追加します。



その後、次のようにビジネスロジックを実装できます。



 override suspend fun doTask(): Result = withContext(CommonPool) { if ( !isSessionValidForTask() ) { return@withContext Result.Error(Exception()) } … try { Result.Success(restApi.call()) } catch (e: Exception) { Result.Error(e) } }
      
      





Coroutine context — Coroutine builder withContex .



, :



— , error. .

— RestApi -.

— , Result.Success .

— , Result.Error .



- , ExceptionHandler .



Result classes , . Result classes, ExceptionHandler try-catch.



3.



, . unit- , , . unit-.



, . , unit-, 2 :



  1. Replacing context . , ;
  2. Mocking coroutines . .


Replacing context



presenter:



 val login() { launch(UI) { … } }
      
      





, login , UI-. , , . , , unit-.



:



 val login (val coroutineContext = UI) { launch(coroutineContext) { ... } }
      
      





— login coroutineContext. , . Kotlin , UI .

— Coroutine builder Coroutine Contex, .



unit- :



 fun testLogin() { val presenter = LoginPresenter () presenter.login(Unconfined) }
      
      







LoginPresenter login - , , Unconfined.

Unconfined , , . .



Mocking coroutines



— . Mockk unit-. unit- Kotlin, . suspend- coEvery -.



login githubUser :



 coEvery { apiClient.login(any()) } returns githubUser
      
      





Mockito-kotlin , — . , , :



 given { runBlocking { apiClient.login(any()) } }.willReturn (githubUser)
      
      





runBlocking . given- , .



Presenter :



 fun testLogin() { val githubUser = GithubUser('login') val presenter = LoginPresenter(mockApi) presenter.login (Unconfined) assertEquals(githubUser, presenter.user()) }
      
      





— -, , GitHubUser .

— LoginPresenter API, . .

presenter.login Unconfined , Presenter , .



! .













便利なリンク





ニュース



30 Mail.ru . , .



AppsConf , .



, , , .



youtube- AppsConf 2018 — :)




All Articles