Upgrade to Pro — share decks privately, control downloads, hide ads and more …

iOSエンジニアがAndroid・Kotlinでの開発を加速させた 3年間の実践テクニック(簡易版)

Fumiya Sakai
September 09, 2024

iOSエンジニアがAndroid・Kotlinでの開発を加速させた 3年間の実践テクニック(簡易版)

9月9日にヤプリ様で開催された「(Unofficial) DroidKaigi 2024 Pre Party 〜全然野菜〜🥦」&9月24日に開催されたSwift愛好会vol.84の登壇資料になります。

2020年に業務を通じてAndroidアプリ開発やKotlinを学ぶ機会を得るまで、これらの知識は全くのゼロでした。最初は心配と試行錯誤の連続でしたが、効率よく楽しみながらキャッチアップし、得た知識を実務で活かしてきました。

キャッチアップの過程で得られたヒント、基本理解から応用を可能にするための着眼点や考え方、エッセンスを紹介します。さらに、3年間の経験を経てモバイルアプリ開発に対する私の考え方がどのようにポジティブに変化したかについてもお話しします。

加えて、iOS/Androidのネイティブアプリを並行して開発する際に直面する課題と、それを克服するための戦略についても触れます。SwiftとKotlinの違いを理解し、効率よく開発を進めるためには、違いをネガティブに捉えるのではなく、共通点や相違点を上手に紐解く姿勢が重要です。

本発表では、Kotlinでの実装を中心に、Swift(iOS)との比較を交えながら以下のポイントを解説します:

1. UI実装・レイアウト処理の違いを見極める方法
2. SwiftとKotlinの共通点や類似点を探し出す方法
3. iOSとAndroidで明確に異なるポイントを知るためのヒント

これらのポイントを具体的な事例を通して紹介し、似た機能やUI実装を要件から具体化するプロセスやアプローチも解説します。

Fumiya Sakai

September 09, 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. Android開発を初めた3年前から今までを振り返る iOSエンジニア経験を活かしながら実務内でのキャッチアップの進め方を紹介 これまでの歩みをダイジェストの様な形で事例紹介をする形になりますが、今後のヒントになれば嬉しく思います。 1. iOSエンジニア経験はそこそこ長かったがAndroid開発を始めたのは2020年から: 取り組む前はAndroid開発に挫折してしまった経験があったので、最初は「少し怖いな…」という印象も持っていました。 基本事項を自分なりに整理したり、簡単なサンプルを動かしたり、実務を重ねたりした経験からポジティブになる事ができた。 2. 易しい所からの積み重ねを経て共通点・相違点に注目して学びを深める: 始めたて・覚えたての時点ではなかなか感覚が掴めない場合もきっとあると思います。私の様にiOSアプリ開発経験の中で得られ

    た知識や経験をヒントにしながら実践を重ねていく方針で少しずつ進めていく方法をもありそうに感じています。 3. iOS/Android間を比較した際のUI実装方針や特徴を理解する事で道が開けた: 業務内ではiOSはSwiftUI/UIKit・AndroidはXML Layout/Jetpack Compose・BackendはGraphQLを活用した開発を実践しています。 特に私はUI実装関連のトピックに強く関心があるので、その観点から様々なケースを探る様にしています。
  3. 最初は不安のほうが大きいけど少しずつ理解を深める いきなりコードを読む衝動を少し押さえてインプットをメインにする 最初は基礎を徹底的に固める 簡単〜標準?位のタスクを実施 Kotlin/AndroidStudioに触れる 早い段階で実施したこと: 基本事項をノートに自分でまとめて いきながら実務で頻出な部分を重点 的に&自分なりにしつこく書き記す 感じで書き記していく。

    (これは自分が慣れた方法) ・基本のさらに基本を知る 両方一緒に 早い段階で実施したこと: 読みながら仕様と基礎を理解する 操作方法 / デバッグ手法 / 実機 ・Kotlinの書き方を知る Swiftの文法をヒントに読み進める 早い段階で実施したこと: 従来通りSwiftのUIサンプルを作る が、それに合わせるバックエンド側 部分をServer Side Kotlinで簡単な 物を試してみる。 (Backend側も知れるのでお得) ・アプリにおける基本理解 ライフサイクルやAndroid機能等 ・UI実装部分における理解 iOSアプリとの共通点&相違点 ・実務で使われるものの理解 RxJava / Dagger / Apollo … ・複雑な構造を組み立てる RecyclerView / ViewHolder …
  4. ① 感覚が似ていそうな部分を理解のヒントにして進める AutoLayout(iOS) & ConstraintLayout(Android)を比較した際の所感 10 24 24 10 CenterX:280

    CenterY:200 相対的な位置関係の調節を コードまたはGUIで実装 <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content"> <View android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="10dp" app:layout_constraintBottom_toBottomOf="10dp" app:layout_constraintStart_toStartOf="24dp" app:layout_constraintEnd_toEndOf="24dp" /> contentView.addSubview(childView) childView.snp.makeConstraints { make in make.top.equalToSuperview().offset(10.0) make.right.equalToSuperview().offset(24.0) make.left.equalToSuperview().offset(24.0) make.bottom.equalToSuperview().offset(10.0) } contentView.addSubview(childView) childView.snp.makeConstraints { make in make.centerX.equalToSuperview() make.centerY.equalToSuperview() make.height.equalTo(200.0) make.width.equalTo(280.0) } <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center_horizontal" android:orientation="vertical"> <View android:layout_width="280dp" android:layout_height="200dp" /> iOS: AutoLayout (with SnapKit) Android: ConstraintLayout & Gravity ① 相対配置 ② 中央寄せ ① ② ②
  5. 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) RxSwift/RxJavaを利用した実装ロジックを読み解く機会 この様にiOS/Android間で近い形に合わせるとキャッチアップ時にも役立つ スレッドや非同期通信時 処理はある程度類似した 形に合わせられる Android: RxJavaを利用 Point2: iOS: RxSwiftを利用 RxSwift / RxJavaでの記 法をヒントにして実装す る(類似Operator) Maybe<Dto> Maybe<Dto> - 直列: flatMap - 並列: Single.zip
  6. 個人的に感覚が類似していそうと感じた事例紹介 遠そうに見えて実は仕組みや実装方針を見ると意外に近い物もある これまでの経験の中で親しんできたiOS(Swift)・Ruby(Ruby on Rails)関連の知見が理解の助けとなった例 (1) DataSource(差分更新) (2) UnitTest(Mock&Stub) iOS

    表示要素を組み立てる処理イメージが近い 🌾 RecyclerViewとUICollectionViewはかなり考え方が違う点 Android UICollectionViewComposit- ionalLayout NSDiffableDataSource Groupie https://github.com/lisawray/groupie Adapter Instanceに対して表示 要素をListて追加するイメージ iOS 表示要素を組み立てる処理イメージが近い 🌾 RecyclerViewとUICollectionViewはかなり考え方が違う点 Android Quick + Nimble SwiftyMocky, Mockolo Spek2 https://www.spekframework.org/ Mockito https://github.com/mockito/mockito
  7. この様な画面実装を題材として処理を考えてみます 検索結果画面表示の中に広告作品を一定の規則性を持たせて表示する場合 検索結果 … 広告結果 … この画面での表示規則: 検索結果表示内に広告がミックス された形になる。 1.検索結果は4行2列で並ぶ

    2.広告結果は1行2列で並ぶ 必ず規則が担保できるか? 1ページあたりの最大数: 1.検索結果は32件表示 2.広告結果は8件表示 ① 検索結果32件/広告結果5件: (1) 広告結果 < 検索結果 ÷ 4 ② 検索結果9件/広告結果3件: UICollectionViewやRecyclerViewのでの並び順を実現する際に綺麗に見せるための 調整が必要になる場合もある。 検索結果画面は表示処理時にページネーションを伴う場合が多い。 実装時に考慮したいポイントと考え方 - Array<T>やList<T>で表示対象データを調整しやすい形にしたい - iOSのDiffableDataSourceを組み立てる様にAndroid側も整えたい (2) 広告結果 + 検索結果 = (奇数) 1.ページの終端に到達した場合 2.検索結果は少ないが広告結果が多く取得できた場合
  8. 題意を満たす並び順を実現する処理部分の抜粋 取得できたデータに対して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要素を指定個数のかたまり分割する
  9. この様な形でPaginationを伴う様な一覧表示を考える SwiftUIを利用した場合のUI実装を元にしてAndroidとの相違点を考えてみる どの様な形で「次のPage表示要素を取得するか」を決定する: iOS17までのSwiftUIではそのままではScrollViewの変化量を取得できない点 セル要素が表示されたタイミングを次のPage表示要素を取得するためのトリガーにする ※1. セル要素内で .onAppear { …判定処理…

    } の様な形にしておく。 ※2. 取得完了をしたタイミングで表示要素の最後尾に次のPage要素を追加する 1ページ目 2ページ目 🌾 スクロール要素の最下端に到達したイベントを取得する方針も実は結構難しい func scrollViewDidScroll(_ scrollView: UIScrollView) func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) UIScrollViewDelegate: UITableViewDelegate: UICollectionViewDelegate: ※UIKitでは下記を利用
  10. Section要素の概要とレイアウトを組み立てる際の概要 全体で見ると難しそうだが要素を丁寧に分解してみると考えやすくなる 表示要素3つを引数で渡す想定で考えてみると良い: Jetpack Compose階層構造: ConstraintLayout - AsyncImage (左側の大きな画像) -

    HorizontalDivider (縦線をPaddingに合わせて) - AsyncImage (右上側の小さな画像) - Divider (横線をPaddingに合わせて) - AsyncImage (右下側の小さな画像) (参考) SwiftUI階層構造: HStack (with Padding) - AsyncImage (左側の大きな画像) - VStack (with Padding) - AsyncImage (右上側の小さな画像) - AsyncImage (右下側の小さな画像) この様に1つの要素内に6 つの部品があると考える イメージを持つと良い。 https://fvilarino.medium.com/creating-a- dynamic-grid-in-jetpack-compose-35f6cb71fd55 https://www.youtube.com/watch?v=txQqhuSZ9xQ SwiftUI 2.0 Compositional Layout - Instagram Feed Layout - SwiftUI Tutorials Width・Height 計算は別途必要
  11. Paging3を利用したPagination処理と組み合わせる Paging3を利用したアーキテクチャの概要とデータ取得処理を結合する箇所 Screen View 公式Documentで推奨しているPaging3利用時のArchitecture: https://developer.android.com/topic/libraries/architecture/paging/v3-overview ViewModel UseCase PagingSource Repository

    Infrastructure 自分が経験した際のArchitecture概要: UseCase内部でPagingSourceを 初期化する様な形にする。 ※実装ポイント① ※個人的に事前に知っておくと良さそうな部分 1. データ取得処理を実際に実行する場所はどこ? PagingSouce内部でRepositoryクラスの処理を実行する。 Pagination処理はPagingSouceにお任せするイメージ。 2. Flow<PagingData<T>>部分の処理は何をしている? 取得したPagingをFlow型で返す&Paging処理の設定をする。 PagingSourceで取得結果と広 告結果をMergeして返却する。 ※実装ポイント② ViewModel内ではFlow<PagingData<t>>の作成処理をする。 画面では.collectAsLazyPagingItems()で取得して利用する。 ※実装ポイント③
  12. 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
  13. スクロールに連動した日付選択タブの動きを作る(2) Scroll変化量を取得する必要があるのでScrollViewを拡張するアプローチ GeometryReader & PreferenceKey の組み合わせ: public struct ScrollViewWithVerticalOffset<Content: View>:

    View { let onOffsetChange: (CGFloat) -> Void let content: () -> Content public init( onOffsetChange: @escaping (CGFloat) -> Void, @ViewBuilder content: @escaping () -> Content ) { self.onOffsetChange = onOffsetChange self.content = content } // ポイント: ScrollView(.vertical) 内部でGeometryReaderを利用する // 👉 Scroll変化量を取得できる様な形にしている } // ① body部分 public var body: some View { ScrollView(.vertical) { offsetReader content() .padding(.top, 0) } .coordinateSpace(name: "frameLayer") .onPreferenceChange(OffsetPreferenceKey.self, perform: onOffsetChange) } // ② Offset値読み取り処理部分 private var offsetReader: some View { GeometryReader { proxy in Color.clear .preference( key: OffsetPreferenceKey.self, value: proxy.frame(in: .named("frameLayer")).minY )   } .frame(height: 0) } } private struct OffsetPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = .zero static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {} }
  14. スクロールに連動した日付選択タブの動きを作る(3) 画面全体構造における基本方針は「ZStack & ScrollView & LazyVStack」の構成 全体を構成するTimetableView.swiftにおけるScrollViewのOffset値と関連するAnimation処理の抜粋: ZStack(alignment: .topLeading) {

    ZStack(alignment: .top) { // ※ …(画面上部に固定表示される要素を記述)… } ScrollViewWithVerticalOffset( onOffsetChange: { offset in shouldCollapse = offset < verticalOffsetThreshold }, content: { Spacer().frame(height: 130) LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { Section( header: TimetableDayHeader( selectedDay: viewModel.state.selectedDay, shouldCollapse: shouldCollapse, onSelect: { [weak viewModel] in viewModel?.selectDay(day: $0) } ) .frame(height: shouldCollapse ? 53 : 82) .animation(.easeInOut(duration: 0.08), value: shouldCollapse) ) { // ※ …(一覧表示要素を記述)… } } // Determines whether or not to collapse. private let verticalOffsetThreshold = -142.0 // When offset value is exceed the threshold, TimetableDayHeader collapse with animation. @State private var shouldCollapse = false TimetableHeader.swiftの動き 15 16 Day2 Day3 14 Day1 Day2 Day3 Day1 Collapse or Expand ① 取得したOffset値がしきい値を超えたかどうかを見る ② shouldCollapse値の変化でAnimation発火
  15. スクロールに連動した日付選択タブの動きを作る(4) Header要素内部の表示についてもOffset値を基準として表示内容を変更する .background(alignment: .center) { GeometryReader { geometry in Capsule()

    .fill(AssetColors.Primary.primary.swiftUIColor) .frame( width: calculateButtonWidth(deviceWidth: geometry.size.width), height: calculateButtonHeight() ) .offset(x: calculateDynamicTabHorizontalOffset(deviceWidth: geometry.size.width), y: 10) .animation(.easeInOut(duration: 0.16), value: selectedDay) } } TimetableDayHeader( selectedDay: viewModel.state.selectedDay, shouldCollapse: shouldCollapse, onSelect: { [weak viewModel] in viewModel?.selectDay(day: $0) } ) Button { onSelect(day) } label: { VStack(spacing: 0) { Text(day.name) .textStyle(TypographyTokens.labelSmall) if !shouldCollapse { Text("\(day.dayOfMonth)") .textStyle(TypographyTokens.headlineSmall) .frame(height: 32) } } .padding(shouldCollapse ? 6 : 4) .frame(maxWidth: .infinity) .foregroundStyle( // ※selectedDayに応じて背景色を変化 ) } Button背景Modifier Button要素本体 変数: shouldCollapseを元に高さを調節
  16. スクロールに連動した日付選択タブの動きを作る(5) Animationを伴う背景部分とButton配置部分を分離してGeometryReaderを利用 3つ横並びのButton要素に定義したModifier: .background(alignment: .center) { GeometryReader { geometry in

    Capsule() .fill(AssetColors.Primary.primary.swiftUIColor) .frame( width: calculateButtonWidth(deviceWidth: geometry.size.width), height: calculateButtonHeight() ) .offset(x: calculateDynamicTabHorizontalOffset(deviceWidth: geometry.size.width), y: 10) .animation(.easeInOut(duration: 0.16), value: selectedDay) } } private func calculateDynamicTabHorizontalOffset(deviceWidth: CGFloat) -> CGFloat { let buttonAreaWidth = calculateButtonWidth(deviceWidth: deviceWidth) // Get the index value corresponding to `selectedDay` and use it for calculation let indexBySelectedDay = getIndexBySelectedDay() return buttonAreaLeadingMargin + (betweenButtonMargin + buttonAreaWidth) * CGFloat(indexBySelectedDay) } private func calculateButtonWidth(deviceWidth: CGFloat) -> CGFloat { // Calculate button width considering related margins let excludeTotalMargin = calculateExcludeTotalMargin() return (deviceWidth - excludeTotalMargin) / CGFloat(buttonsCount) } この様に分離して考えています 14 15 16 Day1 Day2 Day3 Slide Animation 本 体 背 景 ① X軸方向のOffset値をGeometryReaderより算出 ② ボタン背景の幅をGeometryReaderより算出
  17. まとめ iOS/Androidにおける開発時の操作や流れを把握し共通点・相違点を紐解く態度 未だに苦手な部分も多々ありますが、iOS/Android両方の開発に携わる楽しさが今は多いと感じています。 1. 最初に躓かないために基本事項や操作を整理しながら進める所から始める: どうしても最初に躓いてしまうと、なかなか前に進められない場合もあるので、始めの1歩として基本理解や簡単な物を試す事か ら着手していくと良いと思いました。その中で徐々に慣れていきながら実務に取り組んでみると良さそうに思います。 2. iOS/Androidにおける類似点・相違点をより深く知り実践やインプットをまとめておく: UI実装やプラットフォーム固有機能をはじめとした、一見すると同じ様に表現している様に見える機能であっても方針がかなり

    異なる場合はよくあります。その様な場合にも対処できる様に工夫すべき点や注意すべき特徴を押さえる事が大切です。 3. iOS/Android開発の観点から両方の実装方針を知る事でアジャイル開発内でも活用できる: 両方の実装方針や特徴を知る事で実現するための難易度を正確に把握する事には大きく役立ちます。良き方向性や形を模索して いく事で、最終的にはスムーズな開発への支援、ひいては円滑な開発や開発生産性向上に繋がっていくと考えています。