Kotlin Multiplatform Mobileで作成したライブラリをSpecs Repoを使って配布する時の設定

KMMを使ってライブラリを作成しCocoapodsで配布する際に、デフォルトのpodspecファイルだとLinterでエラーになり配布できませんでした。
試行錯誤した結果、下記のような修正を行うと無事に配布することができました。

spec.sourceの変更

初期値のままだとエラーになります。
URLを指定しましょう。

Before

{ :http=> ''}

After

{ :git => "git@github.com:ユーザー名/リポジトリ名.git", :tag => spec.version }

spec.preserve_pathsの追加

preserve_pathsで指定されたファイル以外はダウンロード後に削除されてしまうようなので削除されたくないファイルを指定します。
下記のように全てのファイルを残すやり方でもいけました。

spec.preserve_paths = "**/*.*"

spec.vendored_frameworksの変更

プロジェクトのルートから見た相対パスを指定する必要がありました。

Before

'build/cocoapods/framework/shared.framework'

After

"shared/build/cocoapods/framework/shared.framework"

spec.script_phasesの変更

REPO_ROOT=プロジェクトのルートになるようなので、その想定で相対パスとGradleタスクのパスを変更します。

Before

"$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \

After

"$REPO_ROOT/gradlew" -p "$REPO_ROOT" :shared:syncFramework \

参考サイト

https://satoshun.github.io/2021/02/kmm-cocoapods-external-source/
https://www.rubydoc.info/github/CocoaPods/Core/Pod

Rustの可変長配列を使用するときのコストについて

Rustでアルゴリズムの問題を解いていた時に、計算量的には通るはずのコードが通らず…配列の操作が怪しそうだったので配列の追加・削除の速度について調査しました。
前提として、今取り組んでいるアルゴリズムの問題は実行時間を1sに収める必要があるかつ計算回数が約350万回です。
100万回操作を行うコードを書いて処理にかかった時間に3.5をかけ、1000msを超えてるケースがないか確認しました。

環境

IntelliJ IDEA 2021.1.3
rustc 1.46.0 (04488afe3 2020-08-24)

固定長配列の要素追加

実行したコードは下記になります。

1
2
3
4
5
6
let mut array = [0; 1_000_000];
let start_time = SystemTime::now();
for i in 0..1_000_000 {
array[i] = i
}
println!("{}", SystemTime::now().duration_since(start_time).unwrap().as_millis());

計測結果は
1回目→42ms
2回目→51ms
3回目→43ms
で平均は45.3msでした。
45.3 * 3.5 = 約160msなので原因にはなりづらそうです。

可変長配列の要素追加

実行したコードは下記になります。

1
2
3
4
5
6
let mut array = Vec::new();
let start_time = SystemTime::now();
for i in 0..1_000_000 {
array.push(i);
}
println!("{}", SystemTime::now().duration_since(start_time).unwrap().as_millis());

計算結果は
1回目→62ms
2回目→63ms
3回目→63ms
で平均は62.7msでした
62.7 * 3.5 = 約220msでこれも原因ではなさそうです。

可変長配列の要素削除(先頭)

実行したコードは下記になります。

1
2
3
4
5
6
7
8
9
let mut array = Vec::new();
for i in 0..1_000_000 {
array.push(i);
}
let start_time = SystemTime::now();
for i in 0..1_000_000 {
array.remove(0);
}
println!("{}", SystemTime::now().duration_since(start_time).unwrap().as_millis());

計算結果は
1回目→124906ms
2回目→107748ms
3回目→99213ms
で平均は110622.3msでした。
完全にこれが原因でした。
VectorでQueueのような挙動を実現しようとしてremove()を使っていたのですが悪かったようです。

【おまけ】可変長配列の要素削除(末尾)

Vectorはスタックとして扱うことができpush()と対となるpop()が準備されています。
それを使ったときの速度がremove()と同じになるか気になったので試してみました。
実行したコードは下記になります。

1
2
3
4
5
6
7
8
9
let mut array = Vec::new();
for i in 0..1_000_000 {
array.push(i);
}
let start_time = SystemTime::now();
for i in 0..1_000_000 {
array.pop();
}
println!("{}", SystemTime::now().duration_since(start_time).unwrap().as_millis());

計算結果は
1回目→82ms
2回目→72ms
3回目→72ms
で平均は75.3msでした。
この速度の違いはいったい…
Rustのコードを見てみたら納得しました。
pop()は単純に今持っている配列のサイズ-1番目の要素を削除するだけですが、remove()はindexで指定した位置の要素を削除した後に、指定した位置より後ろにある要素たちをコピーして削除した要素を埋める形でずらしているようです。
今回の場合、indexの指定が常に0なのでremoveとの相性がかなり悪かったです。

Composableのイベント伝達を止める

Jetpack Composeを使っていると、タップ・ドラッグなどのタッチイベントを他のComposableに伝えず、指定したComposableで消費したいケースがあります。
調査した所、消費したいイベントによっていくつか選択肢がありそうでした。
自分で試した下記3ケースの実装例を紹介します。

  • タップ
  • (特定方向の)ドラッグ
  • タッチイベント全般

タップ

タップだけ消費できれば良い場合は、clickable Modifierに空のイベントを定義しつつindicationをnullにしてRipple Effectが表示されないようにします。

1
2
3
4
5
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
)

(特定方向の)ドラッグ

特定方向のスワイプを無効にしたいケースではdraggable Modifierが使えます。
ただ特定方向と書いている通り、draggableはorientationを指定する必要がある都合上、指定した方向のスワイプしか無効にできません。

1
2
3
4
5
.draggable(
interactionSource = remember { MutableInteractionSource() },
state = remember { DraggableState {} },
orientation = Orientation.Horizontal,
)

タッチイベント全般

スワイプの方向に関係なくタッチイベントを無効にしたい場合はPointerInputScopeの拡張関数であるdetectDragGesturesが使えます。
下記の処理ではドラッグイベントを全て消費することによって他のビューにイベントを与えないようにしています。

1
2
3
.pointerInput(Unit) {
detectDragGestures { change, _ -> change.consumeAllChanges() }
}

おまけ(条件付きタッチイベントの無効)

常にタップやタッチイベントを無効にするというケースは稀で、特定の条件の時のみ無効にしたいというケースがより一般的かと思うのでそのケースも載せておきます。

1
2
3
4
5
6
7
Box(
modifier = if (isLoading) {
Modifier.disableTouchEvent()
} else {
Modifier
}
)

Jetpack Composeでマルチタップを検知する

Jetpack Composeでタッチイベントを制御する際にマルチタップを検知する方法がわからなかったので調査しました。

解決策

PointerEventchangesというプロパティがあります。
changesList型になっておりタッチしている指の数だけPointerInputChangeが追加されるようになっているため、
下記のように、このプロパティのサイズを確認すればマルチタップしているかどうかが判定できます。

1
2
3
if (event.changes.size == 2) {
// 指2本でタップしている場合に行いたい処理
}

補足

公式ドキュメントからはこの使い方が正しいかどうかはわかりませんでした。
が、Jetpack ComposeのAwaitPointerEventScope.awaitFirstDown()を見ると最初のDownイベントを検知するためにevent.changes[0]を確認しているのでそこまで的外れではないのかなぁと思っています。

HorizontalPagerで最初に表示するページを指定する

公私共にJetpack Composeを使ってコードを書く頻度が増えてきました。
宣言的UIという書き方にまだまだ不慣れということもあり、使い方がわからず(ワンチャンライブラリ側の不具合ということもあるかもしれない)ハマることもしばしば…。
今回はそんなハマり所の中で、個人的に多くの時間を費やしてしまったモノを取り上げてみます。

環境

Jetpack Compose関連のライブラリのバージョンは下記の通りです。

1
2
3
4
5
implementation "androidx.compose.material:material:1.0.4"
implementation "androidx.compose.ui:ui:1.0.4"
implementation "androidx.compose.ui:ui-tooling:1.0.4"
implementation "androidx.compose.foundation:foundation:1.0.4"
implementation "com.google.accompanist:accompanist-pager:0.20.0"

問題

タイトルにもある通り、HorizontalPagerを使って最初に表示ポジションを0ではなくnページ目を表示したいという要求がありました。
それに対してJetpack Composeを使ってコードを書いてみたのですがなかなか動かずハマりました。
問題となるコードは下記になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
val position = 3// ここで初回表示したいポジションを指定する
val pagerState = rememberPagerState(position)

HorizontalPager(
state = pagerState,
) {
// ページ単位で表示するレイアウトを表示
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect {
// ページが変化したときに呼び出す処理
}
}

上記のコードだと、LaunchedEffect内のページが変化したときの処理がHorizontalPager初期化時に呼ばれません。
HorizontalPager自体のポジションは期待したものになっていたのでページが変化したときに特に処理を呼び出さないのであれば上記のコードでも問題なく動きます。
調査した結果、HorizontalPagerの初期化前にポジションを渡してしまうとLaunchedEffectでページ変化の処理を観測できないようです。
よくよく考えると当たり前ですね。

解決策

下記のように、HorizontalPagerが呼び出された後にポジションを指定すると、ページ変化の処理を観測しているLaunnedEffectが意図した通り呼ばれるようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val position = 3// ここで初回表示したいポジションを指定する
val pagerState = rememberPagerState()

HorizontalPager(
state = pagerState,
) {
// ページ単位で表示するレイアウトを表示
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect {
// ページが変化したときに呼び出す処理
}
}
LaunchedEffect(Unit) {
pagerState.scrollToPage(position)
}

所感

宣言的UIは、先に状態がありそれをUIに流し込むことで表示したいUIを実現する手法だと考えていて、今回のように宣言順序を考慮しないと行けないケースはなかなか気づきづらいです。
これはLaunchedEffectという副作用のある処理を書いていることに起因している気がするので、HorizontalPagerにViewPagerでいうonPageSelected的なコールバックが追加されれば解決しそうです。
まだ0.20.0なのでこれからに期待。

参考サイト

https://google.github.io/accompanist/pager/
https://google.github.io/accompanist/pager/#reacting-to-page-changes

DroidKaigi 2021に参加しました

10/19~21で行われたDroidKaigi 2021に参加してきました。
忘れないうちに感想を残しておこうと思います。

開催期間中は普通に業務をしており、MTGの合間を縫ってセッションを視聴していました。
現在、週末を使って諸々のアーカイブを見たりして参加者の皆さんに追いつこうとしている状態です。
思ったことを雑に以下の3つにまとめています。

参加のハードルがとても下がった

今回初のオンライン開催でしたが、参加のハードルがとても下がったと感じました。
私自身DroidKaigiへの参加は4回目(2017年から参加)で、満員電車に乗って新宿まで向かいそこから徒歩で会場まで向かっていた頃と比べるとハードルがないに等しいかもしれません。
加えて、冒頭でも述べたとおり業務に追われて中々視聴する時間を取れなかったのですが、各日程終了後すぐにアーカイブ動画の視聴できたため業務終了後の空き時間に視聴することができて助かりました。
一方で、アフターパーティのような他社のエンジニアの方と話したり美味しい料理を食べる機会がなくなってしまったのは少し寂しくもあります。

コンテンツのボリュームがすごい

セッション自体の数はおそらく前回と変わらないか少し減ったぐらいなのかなと思っていますが、今年のDroidKaigiはセッション以外のコンテンツが豊富で、前夜祭やWeekend Chat、DroirKaigi Ninjasといった新しい取り組みがありました。
この辺はまだまだキャッチアップできておらずなんとかしたいです。

新しく何かを学ぶことは楽しい

まだ数セッションしか見れていませんが、

  • 社内のコーディング規約を元にAndroid Lintの警告・エラーを整備すればコードに統一感がでるのは
  • Jetpack Composeは既存のViewシステムに比べるとまだパーツが足りてない感があるけど工夫次第でなんとかなりそう
  • 画像・動画はとりあえずMediaStore APIを使っておけばよさそう

といった知見や開発を進める上での指針を得ることができました。
新しく知見得ると早く試したくてワクワクします。これはカードゲームのデッキを構築する感覚に近いかもしれません。
(ユーザーにとって価値のあるプロダクトを作るという大前提はありつつ)さまざまな技術を積み重ねてつくるプロダクトをデッキ、知見1つ1つをカードに例えた時、新しいカードを手に入れたらそれを今あるデッキとどう組み合わせるとより強いデッキが作れるか考える感覚です。
(そう考えると、DroidKaigiで知見を得ることはランカーの公開されているデッキを見せてもらえる貴重な機会という見方もできそうです。)

おわりに

技術的な側面ばかり書きましたが、個人的にはYoutubeのチャット欄の盛り上がり方も良かったです。(登壇者が自分自身にスパチャを投げるという新しいスパチャ芸?を拝めたのがハイライト)
これからゆっくりとコンテンツを消化していこうと思います。
DroidKaigiを開催・運営して頂いたスタッフおよび関係者の方々に感謝します。ありがとうございました!

Androidで単体テストを書く時にコールバックの戻り値をモックする

あまり出番はないかもしれませんが、たまにあるクラスからコールバックで返ってきた値を使って何か行うというコードを書く時があります。
そのような処理が書かれたクラスを単体テストする方法が分からなかったので調べました。

解決方法

Androidアプリ開発において上記のようなケースで単体テストを書きたい場合はMockitoのInvocationOnMockを使います。
mockito-kotlinを使って書く場合は下記のようになります。

1
2
3
4
5
6
7
8
9
interface Hoge {
fun getOrCreate(listener: (String?) -> Unit)
}

val hoge: Hoge = mock()
whenever(hoge.getOrCreate(any())).thenAnswer { invocation ->
val listener = invocation.arguments[0] as ((String?) -> Unit)
listener.invoke("test")
}

Hogeインターフェースをモックしメソッド実行時の挙動を定義します。
InvocationOnMockからgetOrCreateの第1引数を取り出し、それを発火させます。
その結果、プロダクトコード側のgetOrCreateメソッドがコールバックを返す挙動を単体テストで再現することができます。
Hogeの具象クラス側の実装が単体テストできない時等に有効です。

参考サイト

https://stackoverflow.com/questions/48204784/unit-test-for-kotlin-lambda-callback

バージョンコード取得方法の使い分け

Android開発においてバージョンコードを取得する方法は私の知る限り2つあります。
1つはBuildConfigの定数を参照する方法、もう1つはPackageManagerから取得する方法です。
この2つをどう使い分ければ良いのか考えてみました。

バージョンコードの取得方法

改めてコードベースでバージョンコードの取得方法を確認してみます。それぞれ下記のような実装になります。

BuildConfigの定数を参照する

1
val versionCode: Int = BuildConfig.VERSION_CODE

PackageManagerから取り出す

1
2
3
4
5
6
7
try {
val context = requireContext()
val info: PackageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val version: Int = info.versionCode
} catch (e: PackageManager.NameNotFoundException) {
e.printStackTrace()
}

※API Level 28からはversionCodeではなくgetLongVersionCode()を参照した方が良いみたいです。

使い分け

結論から書くと、マルチモジュールを採用しているかどうかを基準にすると良いです。
なぜならマルチモジュール環境下の場合はappモジュールのBuildConfigを他のモジュールから参照できないため、BuildConfigを使った取得方法は使えません。
(正確には、使えないことはないですが他モジュールがappモジュールを参照する必要が出てくるため、モジュール構造的に無理が出てきたり循環参照が発生しやすくなったりします。)
対してPackageManagerから取得する方法はAndroid SDKが参照できる箇所なら問題なく使えるためマルチモジュール環境でも問題ありません。
バージョンネームの使い分けも同様の考え方で行けると思います。

参考サイト

https://stackoverflow.com/questions/4616095/how-can-you-get-the-build-version-number-of-your-android-application

GroovyでSystem.getenvの戻り値がnullだった時の対処法

build.gradleにおいて下記のように環境変数から値を取り出してごにょごにょする時が稀によくあるのですが、取得できたりできなかったりする環境変数の時に対処に困ったのでメモを残します。

1
hoge = System.getenv("HOGE")

解決方法

Groovyではエルビス演算子が使えます。

1
hoge = System.getenv("HOGE") ?: "Default Value"

知らんかった…

Androidアプリ開発におけるキャッシュについて整理

Androidアプリのビルド時間を短縮する上で、キャッシュの仕組みを知るのはとても大事だと思い、Androidアプリ開発で使用する下記3つのキャッシュについて調査してみました。

  • Incremental Build
  • Gradle Build Cache
  • Android Studioのシステムキャッシュ

Incremental Build

何者

アプリをビルドするとapp/build/配下に生成されるキャッシュたちのことです。
Gradleのタスク単位でキャッシュされており、Task :app:preBuild UP-TO-DATEのようにタスクの右端にUP-TO-DATEという記述がある場合はキャッシュが使用されたことになります。

どこにキャッシュされるか

app/build/直下に生成されます。
より正確にはモジュールのルートディレクトリの直下に生成されます。

キャッシュのクリア方法

Android StudioでClean Projectを選択するかCLIで./gradlew cleanを実行することでクリアされます。

Gradle Build Cache

何者

Gradleタスクのアウトプットをプロジェクトの外側に持つ仕組みで、プロジェクト間でキャッシュの共有ができるようです。
AGPのメジャーアップデート時などはこのキャッシュが悪さをすることが稀にあります。

どこにキャッシュされるか

~/.gradle/cachesにキャッシュされます。

キャッシュのクリア方法

キャッシュ置き場をごっそり削除します。
GUIで消すかCLIの場合はrm -rf ~/.gradle/caches/でも消せます。

Android Studioのシステムキャッシュ

何者

Android Studio自体が持つキャッシュのことです。
Project Structureの情報などが対象みたいです。

どこにキャッシュされるか

公式サイトを見る限り明記はされていません。が、おそらくAndroid Studioアプリ内のキャッシュだろうと推察します。
Android Studioのアップデート時にキャッシュ削除しますか?的なダイアログが出てきて削除している対象がここで言っているキャッシュの可能性がありますね。

キャッシュのクリア方法

Android Studio上でInvalidate Caches / Restartを選択します。
困ったときはだいたいこれで解決しますね。

参考サイト