Slide 1

Slide 1 text

iOSエンジニアがAndroid・Kotlinでの開発を加速させた 3年間の実践テクニック(簡易版) Fumiya Sakai (第1版) 2024/09/09: (Unofficial) DroidKaigi 2024 Pre Party 〜全然野菜〜 (第2版) 2024/09/24: Swift愛好会#84 @ TOKIUM様

Slide 2

Slide 2 text

自己紹介 ・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

Slide 3

Slide 3 text

iOSのUI実装本を執筆しています! 書籍に掲載したサンプルのバージョンアップや続編等に現在着手中です。 少しの工夫で実現できるTIPS集やライブラリ表現の活用集をはじめとした、iOSア プリ開発の中でも特にUI実装やUIKitを利用した画面の中で特徴を与える様な表現 という題材に焦点を当てた書籍となっております。 現在は電子書籍版のみとなります。 こちらは全て¥1,000となっております。 https://just1factory.booth.pm/ 概要: https://book-tech.com/ 価格: 📖 Booth 📖 Book Tech

Slide 4

Slide 4 text

UI実装であると嬉しいレシピブックの最新情報 UI実装であると嬉しいレシピブックVol.3として2022年10月に商業化しました! Still WIP これまでの同人誌として頒布したものに加えて、Vol.1及びVol.2に頒布したものの 中で書籍に載せきれなかったものや表現や動きが特徴的でユーザーにもほんの少し 遊び心を与える様なUI実装を紹介したものをVol.3としています。 概要: これからの構想: こちらで購入可能です: Amazon / Google Play / Apple Books / KINOKUNIYA / Rakuten BOOKS etc.. 🏊 iOS: SwiftUIを利用したUI実装や動画関連の実装 🏊 Android: Jetpack Composeの基本やその他気になるUI表現の考察

Slide 5

Slide 5 text

技術書同人誌博覧会スタッフ&デザイン協力もしてます お申し込みフォーム: https://gishohaku.dev/gishohaku11/circle-entry 2025年1月25日開催の第11回技術書同人誌博覧会のサークル受付開始しました! 皆様のご応募お待ちしております🙏 🍀 #9 一般参加募集チラシ 🍀 #10 次回案内チラシ 🍀 #11 次回案内チラシ 🍀 技書博 mini OSC案内

Slide 6

Slide 6 text

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実装関連のトピックに強く関心があるので、その観点から様々なケースを探る様にしています。

Slide 7

Slide 7 text

最初は不安のほうが大きいけど少しずつ理解を深める いきなりコードを読む衝動を少し押さえてインプットをメインにする 最初は基礎を徹底的に固める 簡単〜標準?位のタスクを実施 Kotlin/AndroidStudioに触れる 早い段階で実施したこと: 基本事項をノートに自分でまとめて いきながら実務で頻出な部分を重点 的に&自分なりにしつこく書き記す 感じで書き記していく。 (これは自分が慣れた方法) ・基本のさらに基本を知る 両方一緒に 早い段階で実施したこと: 読みながら仕様と基礎を理解する 操作方法 / デバッグ手法 / 実機 ・Kotlinの書き方を知る Swiftの文法をヒントに読み進める 早い段階で実施したこと: 従来通りSwiftのUIサンプルを作る が、それに合わせるバックエンド側 部分をServer Side Kotlinで簡単な 物を試してみる。 (Backend側も知れるのでお得) ・アプリにおける基本理解 ライフサイクルやAndroid機能等 ・UI実装部分における理解 iOSアプリとの共通点&相違点 ・実務で使われるものの理解 RxJava / Dagger / Apollo … ・複雑な構造を組み立てる RecyclerView / ViewHolder …

Slide 8

Slide 8 text

2. noteまとめ記事・原稿アーカイブ 業務を始めたてだった当時に定期的に実践していた事 自分自身の業務内外から得られた貴重な学びを形にしてまとめる 1. 過去登壇資料アーカイブ https://www.slideshare.net/slideshow/kotlinvol27-android/242900792 https://note.com/fumiyasac/n/n0acb2bd76b64 https://note.com/fumiyasac/n/nec1c3c80f12d https://speakerdeck.com/fumiyasac0921/androidjian-dehe-waseruhinto https://speakerdeck.com/fumiyasac0921/androidjian-dehe-waseruhintono- dot-2 https://speakerdeck.com/fumiyasac0921/androidjian-dehe-waserudao-biao- no-dot-1 https ꞉ //github.com/fumiyasac/iosdc2021_pamphlet_manuscript/blob/main/ manuscript.md

Slide 9

Slide 9 text

① 感覚が似ていそうな部分を理解のヒントにして進める AutoLayout(iOS) & ConstraintLayout(Android)を比較した際の所感 10 24 24 10 CenterX:280 CenterY:200 相対的な位置関係の調節を コードまたはGUIで実装 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) } iOS: AutoLayout (with SnapKit) Android: ConstraintLayout & Gravity ① 相対配置 ② 中央寄せ ① ② ②

Slide 10

Slide 10 text

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 Maybe - 直列: flatMap - 並列: Single.zip

Slide 11

Slide 11 text

個人的に感覚が類似していそうと感じた事例紹介 遠そうに見えて実は仕組みや実装方針を見ると意外に近い物もある これまでの経験の中で親しんできた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

Slide 12

Slide 12 text

この様な画面実装を題材として処理を考えてみます 検索結果画面表示の中に広告作品を一定の規則性を持たせて表示する場合 検索結果 … 広告結果 … この画面での表示規則: 検索結果表示内に広告がミックス された形になる。 1.検索結果は4行2列で並ぶ 2.広告結果は1行2列で並ぶ 必ず規則が担保できるか? 1ページあたりの最大数: 1.検索結果は32件表示 2.広告結果は8件表示 ① 検索結果32件/広告結果5件: (1) 広告結果 < 検索結果 ÷ 4 ② 検索結果9件/広告結果3件: UICollectionViewやRecyclerViewのでの並び順を実現する際に綺麗に見せるための 調整が必要になる場合もある。 検索結果画面は表示処理時にページネーションを伴う場合が多い。 実装時に考慮したいポイントと考え方 - ArrayやListで表示対象データを調整しやすい形にしたい - iOSのDiffableDataSourceを組み立てる様にAndroid側も整えたい (2) 広告結果 + 検索結果 = (奇数) 1.ページの終端に到達した場合 2.検索結果は少ないが広告結果が多く取得できた場合

Slide 13

Slide 13 text

題意を満たす並び順を実現する処理部分の抜粋 取得できたデータに対して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要素を指定個数のかたまり分割する

Slide 14

Slide 14 text

iOS/Android間で考え方が異なる事例 アスペクト比を考慮したGrid表示&タブ要素と連動するコンテンツを切り替え 🌾 iOSはUIKit / AndroidはXMLでのLayoutを想定した場合

Slide 15

Slide 15 text

iOSでは用意されていないがAndroidでは用意されている表現例 ここでは特徴的なUIコンポーネントをいくつかピックアップしました 🌾 もちろん逆にAndroidには用意されていないがiOSでは用意されているものもあります。 ʴ "EE"SUJDMFT 5BLJOH1JDUVSFT 4FUUJOHT DrawerMenu FAB Bottom Sheet Toast

Slide 16

Slide 16 text

動画プレイヤー機能など端末に依存するものは特に注意が必要 iOS/Androidで同様な機能に見えても実装方針は大きく異なる場合が多い

Slide 17

Slide 17 text

この様な形で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では下記を利用

Slide 18

Slide 18 text

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 計算は別途必要

Slide 19

Slide 19 text

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>部分の処理は何をしている? 取得したPagingをFlow型で返す&Paging処理の設定をする。 PagingSourceで取得結果と広 告結果をMergeして返却する。 ※実装ポイント② ViewModel内ではFlow>の作成処理をする。 画面では.collectAsLazyPagingItems()で取得して利用する。 ※実装ポイント③

Slide 20

Slide 20 text

class FoodPhotoViewModel( private val useCase: FoodPhotoUseCase ): ViewModel() { … ※今回はpaginationに関する処理を抜粋しています override val foodPhotoStream: Flow> 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

Slide 21

Slide 21 text

DroidKaigi2023で実施したTOP画面表示のカスタマイズ SwiftUIではNestedScrollViewの様な挙動を実現する際に少し苦労しました

Slide 22

Slide 22 text

スクロールに連動した日付選択タブの動きを作る(1) Android側のView構造を整理した上でiOS側で実現可能な手段を探す TimetableScreen.kt TimetableHeader.kt TimetableSheet.kt nestedScrollを利用して変化量を反映する ScrollViewで同様の処理は可能か? 🍎 Offset値取得+Animationの組合せ Header位置を固定 画面のおおもとの部分 Scroll変化量に合わせた処理

Slide 23

Slide 23 text

スクロールに連動した日付選択タブの動きを作る(2) Scroll変化量を取得する必要があるのでScrollViewを拡張するアプローチ GeometryReader & PreferenceKey の組み合わせ: public struct ScrollViewWithVerticalOffset: 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) {} }

Slide 24

Slide 24 text

スクロールに連動した日付選択タブの動きを作る(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発火

Slide 25

Slide 25 text

スクロールに連動した日付選択タブの動きを作る(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を元に高さを調節

Slide 26

Slide 26 text

スクロールに連動した日付選択タブの動きを作る(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より算出

Slide 27

Slide 27 text

2024年のDroidKaigiのContribution例 Rosetta2を有効にしている時やPathが認識されない問題が出た時は焦った… 昨年と比較すると少な目ですがTryができた: Issue: Pull Request:

Slide 28

Slide 28 text

まとめ iOS/Androidにおける開発時の操作や流れを把握し共通点・相違点を紐解く態度 未だに苦手な部分も多々ありますが、iOS/Android両方の開発に携わる楽しさが今は多いと感じています。 1. 最初に躓かないために基本事項や操作を整理しながら進める所から始める: どうしても最初に躓いてしまうと、なかなか前に進められない場合もあるので、始めの1歩として基本理解や簡単な物を試す事か ら着手していくと良いと思いました。その中で徐々に慣れていきながら実務に取り組んでみると良さそうに思います。 2. iOS/Androidにおける類似点・相違点をより深く知り実践やインプットをまとめておく: UI実装やプラットフォーム固有機能をはじめとした、一見すると同じ様に表現している様に見える機能であっても方針がかなり 異なる場合はよくあります。その様な場合にも対処できる様に工夫すべき点や注意すべき特徴を押さえる事が大切です。 3. iOS/Android開発の観点から両方の実装方針を知る事でアジャイル開発内でも活用できる: 両方の実装方針や特徴を知る事で実現するための難易度を正確に把握する事には大きく役立ちます。良き方向性や形を模索して いく事で、最終的にはスムーズな開発への支援、ひいては円滑な開発や開発生産性向上に繋がっていくと考えています。

Slide 29

Slide 29 text

Thank you for listening !