Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

類似ロジック実装をiOS/Android間で合わせる道標No.1

 類似ロジック実装をiOS/Android間で合わせる道標No.1

DroidKaigi.collect { #8@Tokyo } での登壇資料になります。

過去登壇では、iOS/Android間で類似した様な実装を進めていく上で、いくつかの実例を元にしてiOS/Android両方を効率良く進めるための観点・ヒントや着想を得るポイントとなり得る部分に関して、少し複雑なLayout実装例に焦点を当てて紹介してきました。

今回は、ほんの少し趣向を変えまして、レイアウトを作成する上で欠かせない要素を理解するにあたって、これまでの業務の中で両OSの特徴を比較する事で違いを理解し、キャッチアップをした事例を簡単ではありますが紹介しています。

一見するとイメージや印象が全く違うものだったとしても、処理構造をもう一歩深く調査してみると、ヒントになり得そうな材料がある場面もありますので、UI実装や関連処理ロジックについても「実は感覚が似ている部分」に注目する際の参考になれば幸いです。

※過去の登壇内容も参考にして頂けると幸いです。

● 同じ様なUIをiOS/Android間で合わせるヒント
https://speakerdeck.com/fumiyasac0921/androidjian-dehe-waseruhinto

● 同じ様なUIをiOS/Android間で合わせるヒントNo.2
https://speakerdeck.com/fumiyasac0921/androidjian-dehe-waseruhintono-dot-2

Fumiya Sakai

May 07, 2024
Tweet

More Decks by Fumiya Sakai

Other Decks in Technology

Transcript

  1. 自己紹介 ・Fumiya Sakai ・Mobile Application Engineer アカウント: ・Twitter: https://twitter.com/fumiyasac ・Facebook:

    https://www.facebook.com/fumiya.sakai.37 ・Github: https://github.com/fumiyasac ・Qiita: https://qiita.com/fumiyasac@github 発表者: ・Born on September 21, 1984 これまでの歩み: Web Designer 2008 ~ 2010 Web Engineer 2012 ~ 2016 App Engineer 2017 ~ Now iOS / Android / sometimes Flutter
  2. 過去の登壇ではUI実装に焦点を当てた内容をしました iOSアプリ関連のUI実装への関連からAndroid開発でも同様な視点を持ちました iOS/Androidの並行した実装を進める際には効率良く進める事がポイントとなる。実装からヒントを沢山見つけること。 1. UI実装関連の実例を利用することで共通点や相違点を見出してみよう: 同じ様なUIをiOS/Android間で合わせるヒントNo.1 2. UI実装以外の部分における共通点や相違点を探る事で理解が深まる: 同じ様なUIをiOS/Android間で合わせるヒントNo.2 https://speakerdeck.com/fumiyasac0921/androidjian-dehe-waseruhinto

    https://speakerdeck.com/fumiyasac0921/androidjian-dehe-waseruhintono-dot-2 UI表示直前までの データ作成処理ヒント List操作(iOSでいう所のArray操作)処理等の部分に関しては、考え方が似ている部分が多少あっ たのでその点をヒントにして進めていく方針を心がけていました。その一方で、API関連処理やデー タ永続化処理等の全く異なりそうな部分は、相違点を明確にして進めていく方針を取っています。
  3. RxSwift&RxJavaで処理を合わせる考え方とArchitect 自分がRxSwiftでの実装に慣れていたので素早くキャッチアップできた記憶 APIサーバーより取得したデータの内容を画面に表示する際の処理の例を考えてみる UseCase Presenter Repository層で定義したAPI通信後の処理をそれぞれ定義する。(※One Usecase has many Repositories)

    Repositoryの処理を実行 → flatMapでレスポンス内容を変換 → Single<T> / Maybe<T> / Completableに変換 Repository InfraStructure UseCaseで実行された処理に対してUI側で実施する処理をそれぞれ定義する。 UseCaseの処理を実行 → Main Threadで実行する → 成功時 / エラー発生時 / 処理完了時 に合わせてUI処理を実行 今回例として挙げる部分はこちら REST APIやGraphQLを利用してサーバー側との非同期通信を実施する基盤となる処理 非同期通信の処理結果を元にEntityに変換したり内部キャッシュに保存する処理 ※ RxSwift / RxJavaのStreamに乗せていくイメージ
  4. RxSwift&RxJavaを見比べた際のコード実装例 実際のコードとして実装を当てはめるとこのような形にできる(Presentation層) doSomethingUseCase.execute() .observeOn( uiScheduler ).subscribeBy( onSuccess = { dto

    -> // MEMO: 処理成功時の処理(Viewの組み立て) // → iOS側とほんのちょっと違う部分 view.setUpContent( SomethingConverter.convertToBindingModel(dto) ) }, onError = { error -> // MEMO: エラー発生時のハンドリング処理 }, onComplete = { // MEMO: 処理が完了した際に実施する処理 } ).addTo(disposables) Point1: doSomethingUseCase.execute() .observeOn( mainScheduler ).subscribe( onSuccess: { [weak self] dto in guard let weakSelf = self else { return } // MEMO: 処理成功時の処理(Viewの組み立て) weakSelf.view?.setupContent( SomethingConverter.convertToViewModel(dto) ) }, onError: { [weak self] error in // MEMO: エラー発生時のハンドリング処理  }, onCompleted: { [weak self] in // MEMO: 処理が完了した際に実施する処理 } ).disposed(by: disposeBag) スレッドや非同期 通信時処理はある 程度は類似した形 Android: RxJavaを利用 Point2: iOS: RxSwiftを利用 RxSwift / RxJava での記法をヒント にして実装する Maybe<Dto> Maybe<Dto> Maybe<Dto>
  5. サーバー側とのAPI通信処理に関する概要 メールアドレスとパスワードで認証処理を実行する部分に関する例 val okHttpClient = OkHttpClient.Builder() .addInterceptor(loggerInterceptor) .addInterceptor(OnlyBasicAuthorizationInterceptor()) … 途中省略

    … .build() return Retrofit.Builder() .baseUrl(BuildConfig.SESSION_API_URL) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .build() .create(SessionsApiClient::class.java) interface SessionsApiClient { @POST("v1/sessions") fun login( @Body body: HashMap<String, String> ): Single<SessionTokenEntityJson> } sessionsApiClient.login(hashMapOf( EMAIL to email.value, PASSWORD to password.value )) OKHttpとRetrofitを併用したAPI通信処理: ① APIリクエスト実行用Factoryクラス抜粋 ② APIエンドポイント定義 ③ 必要があればModule化しておく @Module internal class XXXModule { @Provides fun provideSessionsApiClient(): SessionsApiClient = SessionsApiClientFactory().create() 実際の処理をする責務ではこんな感じで利用 当該エンドポイントとの通信実行 Log出力やBasic認証に関する処理 Point: RESTful API等の処 理部分においては Builder/Adapterパ ターンを利用する 事が多かった印象
  6. iOS/Androidで頻出のOSSライブラリを調べてみる iOS/Androidのよく用いられているものを知っておくと結構ヒントになることも RxSwift(Ractive Programming), Resolver(Dependency Injection), Alamofire(Networking), SwiftyJSON(JSON Decoder), Kingfisher(Image

    Cache), Quick + Nimble + SwiftyMocky(Unit Testing), … 1. iOS側のライブラリ例 RxJava(Ractive Programming), Dagger2(Dependency Injection), OkHttp + Retrofit(Networking), ExoPlayer(Movie), Picasso(Image Cache), Groupie(RecyclerView), BubbleLayout(Tooltip), Mockito(Mocking), … 2. Android側のライブラリ例 Apollo(GraphQL), lottie(Animation), Firebase(Platform), Realm(Data Storage), JWTDecode(JWT), … 3. iOS/Android両方ある例 iOS開発/Android開発を行ったり来たりしなければいけない場合にはこの恩恵を感じる場面は多いはず。
  7. GraphQLを活用する事でサーバーとの連携をしやすく iOS・Androidで共通のQuery・Mutationをなるべく利用して認識を合わせる サーバーから取得する内容を合わせて、更にUI構造の形もなるべく合わせると理想?: Swift側でのEntity変換処理例 News一覧Query定義 Apollo-Server内の処理例: 内部定義データを返却する処理 // ① News一覧を取得する

    getNews: (parent: any, args: any, context: any) => { // Contextから渡されたNews一覧データをGraphQLで返却するための処理 const result = context.news; return result; }, query getAllNews { getNews { id title date genre } } result.data?.getNews?.compactMap { NewsEntity( id: $0.id, title: $0.title, date: $0.date, genre: $0.genre ) } ?? [] GraphQL経由で取得できた値を 内部で利用するEntity等へ変換 する(Kotlinの書き方準拠)。 // GraphQL用Clientを定義 (Singleton等にまとめる) val apolloClient = ApolloClient.Builder() .serverUrl("https://example.com/graphql") .build() Androidでも方針は同じ: // 取得したResponseをEntityに変換する // .executeはCoroutineでの非同期処理(suspend)で返却 val response = apolloClient.query(HGetAllNewsQuery()) .execute() Infrastructure Repository 処理成功 or 失敗でハンドリング
  8. (iOS)GraphQL側の処理でasync/awaitを利用するコード例 // GraphQLのQuery処理をasync/awaitの処理内で実行する @discardableResult func fetchAsync<Query: GraphQLQuery>( query: Query, cachePolicy:

    CachePolicy = .default, contextIdentifier: UUID? = nil, queue: DispatchQueue = .main ) async throws -> GraphQLResult<Query.Data> { // MEMO: withCheckedThrowingContinuationでErrorをthrowする形にしています。 return try await withCheckedThrowingContinuation { continuation in fetch( query: query, cachePolicy: cachePolicy, contextIdentifier: contextIdentifier, queue: queue ) { result in switch result { case .success(let value): continuation.resume(returning: value) case .failure(let error): continuation.resume(throwing: error) } } } } // GraphQLのMutation処理をasync/awaitの処理内で実行する @discardableResult func performAsync<Mutation: GraphQLMutation>( mutation: Mutation, publishResultToStore: Bool = true, queue: DispatchQueue = .main ) async throws -> GraphQLResult<Mutation.Data> { // MEMO: withCheckedThrowingContinuationでErrorをthrowする形にしています。 return try await withCheckedThrowingContinuation { continuation in perform( mutation: mutation, publishResultToStore: publishResultToStore, queue: queue ) { result in switch result { case .success(let value): continuation.resume(returning: value) case .failure(let error): continuation.resume(throwing: error) } } } } iOSでのCombineやasync/awaitの処理がCoroutineやFlowになるイメージ Query Mutation
  9. よく利用するRecyclerViewでの実装方法(2) 画面を構成するFragmentやViewHolder1個分の要素と関係を簡単にまとめてみる Fragment Fragment••Binding fragment_xxx.xml - ConstraintLayout - Toolbar -

    RecyclerView fragment_xxx.xml内に配置 した要素をIDで取得可能 配置したRecyclerViewに適用するAdapter初期化時に下記も一緒に渡す ① ViewHolderタップ時のEventListener / ② BindingModel XML内では android:text=“@{bindingModel.name}”の様にできる Adapter ViewHolder ※View要素内ではbinding.bindingModelで取得 ・BindingModel内のデータを元にViewHolderを生成処理を実施 ・ViewHolderタップ時のEventListenerとのBindもここで行う ※ライブラリ「Groupie」を利用 view_xxx.xml ViewHolder1個分のLayout BindingModel View要素に表示対象のデータ View••Binding ※Fragment同様XMLとBinding 例) binding.recyclerView
  10. Groupieを利用したRecyclerViewの構築処理の理解 RecyclarViewに適用するAdapterをGroupAdapter(Groupie提供)に変更する GroupAdapter Section用ViewHolder × 1 初めてGroupieを使ったからまとめました: https://qiita.com/zoothezoo/items/487c3c90869658448307 Simplifying RecyclerView

    using Groupie: https://medium.com/@soosyamoora/simplifying-recyclerview-using-groupie-19ef361e52b5 Creating complex feed based on RecyclerView with Groupie: https://mvaluyskiy.medium.com/creating-complex-feed-based-on-recyclerview-with-groupie-1909df9b381c Item用ViewHolder × n SectionやItemをGroupAdapterへ追加する事でデータが反映 従来のRecyclerViewよりも直感的&シンプルな実装にできる点が魅力 Groupieを用いたRecyclerViewの実装 イメージがUICollectionViewを利用 した実装イメージに似ていた。 結構イメージ近いかも?:
  11. 当時参考にしたのはUICollectionViewでの実装方針(1) iOS13以降〜追加されたDiffableDataSourceの構築処理と改めて比較してみた ① Section用のEnumを定義する(associated valueを用いてHeader・Footerに表示するViewObjectを作成する) 概要をまとめると下記の様な形となる : ② Item用のEnumを定義する(associated valueを用いてCellに表示するViewObjectを作成する)

    ③ 表示順番に配慮してNSDiffableDataSourceSnapshot<◆◆SectionType,◆◆ItemType>に反映する // MEMO: 変数currentSnapshotに表示対象のSectionTypeとItemTypeを追加する際のalias typealias SnapshotElement = (section: [TopSectionType], items: [TopItem]) private var currentSnapshot = NSDiffableDataSourceSnapshot<TopSectionType, TopItem>() // MEMO: ViewControllerと共有しているDiffableDataSource private var dataSource: TopViewController.TopDataSource! ※viewDidLoad時にViewControllerから渡されるもの ※DataSourceに追加するSnapshot Task { @MainActor in do { let response = try await self.topResponseUseCase.getResponse() self.updateTopDataSource(using: response) } catch let error { // TODO: Error Handling. } } 例. APIリクエストからDataSourceに加工するまでの流れ 1. 順番に配慮した状態でのAPIレスポンスデータを取得する 2. レスポンスデータを元にしてSnapshotに反映して更新
  12. 当時参考にしたのはUICollectionViewでの実装方針(2) 取得したデータを表示対象のSection用データに変換した後に反映する点に注目 UICollectionViewCompositionalLayout & NSDiddableDataSourceを利用した処理でのコード抜粋 : private func updateTopDataSource(using responses:

    [TopResponse]) { // 1. 現在Snapshopをリセットする currentSnapshot = NSDiffableDataSourceSnapshot<TopSectionType, TopItem>() // 2. 引数から取得したレスポンスの型を元に分解してSnapshotを生成する for response in responses { if let topBannerResponse = response as? TopBannerResponse { let snapshotElement = getTopBannerSnapshot(topBannerResponse) appendSnapshotElementInCurrentSnapshot(snapshotElement) } …(表示する対象のResponseの分だけSnapshotを生成する処理をする)… } // 3. DiffableDataSourceにデータを反映する dataSource.apply(currentSnapshot, animatingDifferences: true) } // メンバ変数: currentSnapshot(反映対象セクションデータの入れ物)に格納する private func appendSnapshotElementInCurrentSnapshot(_ snapshotElement: SnapshotElement) { currentSnapshot.appendSections(snapshotElement.section) currentSnapshot.appendItems(snapshotElement.items, toSection: snapshotElement.section.first!) } private func getTopBannerSnapshot(_ topBannerResponse: TopBannerResponse) -> SnapshotElement { // バナーに関するデータの実体がある場所 let content = topBannerResponse.content …(表示する対象のResponseの分だけSnapshotを生成する処理をする)… let section = TopSectionType.banner(title: "2022 A/W Selection") let item = TopItemType.banner(bannerViewObject: TopBannerCell.CellViewObject( id: content.id, identifier: content.label, imageUrl: content.imageUrl) ) return SnapshotElement(section: [section], items: [item]) } ① 受け取ったResponseを分解&Section作成 ② 反映対象のSnapshot変数への追加 SectionとItemをDataSourceに追加する点は結構似ている
  13. class FoodPhotoViewModel( private val useCase: FoodPhotoUseCase ): ViewModel() { …

    ※今回はpaginationに関する処理を抜粋しています override val foodPhotoStream: Flow<PagingData<FoodPhoto>> by lazy { Pager(PagingConfig(pageSize = 24, initialLoadSize = 24)) { useCase.foodPhotoPagingSource() }.flow.cachedIn(viewModelScope) … } Paging3を利用したPagination処理例と表示関連部分 Section単位でのList要素がFlowで返却される&Section値でレイアウトが決定 (参考1) https://tech.pepabo.com/2021/10/18/android-paging3/ (参考2) https://speakerdeck.com/ticktaku77/shi-jian-paging-3 @Composable fun CatListScreen(viewModel: CatViewModel) { val pagingItems = viewModel.foodPhotoStream.collectAsLazyPagingItems() SwipeRefresh( state = pagingItems.loadState.refresh is LoadState.Loading, onRefresh = { pagingItems.refresh() } ) { LazyColumn { // Section毎のView要素を表示する … ※以降にエラー発生時のHandling処理を追加する } } } Section: 0 Section: 1 Section: 2 Section: 3 ① LeadingLarge ② TrailingLarge ③ SmallSet 表示データを3つ格納した List要素を更にListに格 納したものが返却される Sectionを選出基準はどの様に定める? ① Section / 4 = X とする。 ② Y = X % 2 の結果で判定する。 Y = 0 Y = 1
  14. この様な画面実装を題材として処理を考えてみます 検索結果画面表示の中に広告作品を一定の規則性を持たせて表示する場合 検索結果 … 広告結果 … この画面での表示規則: 検索結果表示内に広告が ミックスされた形になる。 1.検索結果は4行2列で並ぶ

    2.広告結果は1行2列で並ぶ 必ず規則が担保できるか? 1ページあたりの最大数: 1.検索結果は32件表示 2.広告結果は8件表示 ① 検索結果32件/広告結果5件: (1)広告結果<検索結果÷4 or (2)広告結果+検索結果=(奇数) の場合 ② 検索結果9件/広告結果3件: UICollectionViewやRecyclerViewのでの並び順を実現する際に綺麗 に見せるための調整が必要になる場合もある。 検索結果画面は表示処理時にページネーションを伴う場合が多い。 1.ページの終端に到達した場合 2.検索結果は少ないが広告結果が多く取得できた場合 実装時に考慮したいポイントと考え方: Array<T>やList<T>で表示対象データを調整しやすい形にしたい iOSのDiffableDataSourceを組み立てる様にAndroid側も整えたい
  15. 題意を満たす並び順を実現する処理部分の抜粋 取得できたデータに対してchunk処理を利用した後に表示データへ変換する chunkedSearchProductsGroup .getOrNull(loopIndex)?.let { … 追加処理 … } (Kotlin)

    chunked(size: Int): https://kotlinlang.org/api/latest/jvm/ stdlib/kotlin.collections/chunked.html Kotlinの場合は下記の様になる: Swift ⇔ Kotlin内で内部処理ロジックを読み替えられそうな余地を探す chunk処理がこの表示をするポイント: (Swift) chunks(ofCount: Int): Array要素を指定個数のかたまり分割する
  16. PagingSource内部で実行するデータ取得〜Merge処理 取得結果と広告結果を取得した後に特定Index値の部分を広告結果に置き換える Kotlinの「chunked(size: Int)」を利用する事でSection用データを作成する: (1) まずは従来通りの写真取得結果 + 広告結果をRequest Coroutineを利用して表示に必要なデータを取得する。 (2)

    Page番号(現在何Page目か?)を基にして該当Index値を広告作品に置換 (3) 広告作品を混ぜた一覧List要素をchunkedする ※1. 広告結果が取得できない時は、そのまま写真取得結果を表示する。 ※2. 複数のRequestが必要な場合には、ZipないしはCombineLatest等を活用する。 奇数Page番号 偶数Page番号 Section要素内の格納順番と取得できた写真取得結果のIndex値を考慮する ※1. 24件より少なく該当Indexがない場合は置換処理は実施しない 1Page分の表示内容リスト:[[取得結果3個分], [取得結果3個分], … ] ※1. 取得結果3個分の中に広告作品が混ざっている形となっている
  17. まとめ iOS/Androidの両方を実装を見比べて見ると、実はそう遠くない部分もある 今後もiOS・Android両方のネイティブアプリ関連を知見を活かしていくため、このテーマに向き合おうと思います。 1. 言語・コンポーネント・アーキテクチャの違い等はあるけど類似点・共通点を探してみる: 実装のイメージが類似している場合には実装方針を見抜くチャンスと捉える様にしています。個人的にはこれまでの経験の中か ら「実装のテイストが似ている部分」を見つけ出す様にするために、現在は両方進んでコードを読む様にしています。 2. iOS/Android間で明らかに考え方が異なる部分は念入りに仕様調査をする: 以前の業務でも苦戦した部分はDIコンテナ関連処理と動画再生関連部分でした。両方進んでコードを読み進めていく際に、考え

    方が大きく異なる部分については、サンプル実装をしているOSS等をヒントに基本理解を進める様にしています。 3. 1粒で2度美味しいを実現するために「ヒントと着想」を得るために: 一見するとイメージや印象が全く違うものだったとしても、処理構造をもう一歩深く調査してみると、ヒントになり得そうな材 料がある場面もあります。UI実装や関連処理ロジックについても「実は感覚が似ている部分」に注目すると良さそうです。 UI実装や周辺処理が好きなので個人的に一番楽しい部分