Kotlinの可変長引数とフォーマット
Kotlinの可変長引数とフォーマットを利用した置き換えで詰まったのでメモ
可変長引数
メソッドの引数として定義するときは vararg
を付けます。
fun someMethod(vararg args: String) { args.forEach { println(it) } } someMethod("one") // ok someMethod("one, two") // ok someMethod(arrayOf("one", "two")) // error someMethod(arrayListOf("one", "two")) // error
フォーマット
文字列内で置き換えできるやつです。
fun greet(name: String): { val greetText = "Hello %s." println(greetText.format(name)) } greet("kotlin") // Hello kotlin.
ハマったとこ
可変長引数の展開
次のようなフォーマットに対して
val singleGreet = "Hello %s."
次の2つは結果が異なります。
- フォーマットに直接指定
singleGreet.format("everyone") // Hello everyone.
- フォーマットに関数を経由して指定
fun greet(vararg args: String) { singleGreet.format(args) } greet("everyone") // Hello [Ljava.lang.String;@30dae81.
これは、greet関数の引数で可変長引数としているため、formatにはArray
として利用されるからです。
避けるためには、可変長引数を展開してあげる必要があります。
展開するには、可変長引数の先頭に *
を付けます。(spread演算子)
- 可変長引数を展開
fun greet(vararg args: String) { singleGreet.format(*args) } greet("everyone") // Hello everyone.
どちらもコンパイルが通るのでアレですが、空気を読んでほしかった部分はあります。
冒頭で書いた関数も、spread演算子を使えばコンパイルが通ります。
someMethod(*arrayOf("one", "two")) // ok
フォーマットが複数ある場合も展開するときれいに収まります。
val multiGreet = "Hello %1\$s, and %2\$s." // これでも出来るが `*` を使うほうが変更に強い fun greetMultiNoSpread(vararg args: String) { multiGreet.format(args[0], args[1]) } greetMultiNoSpread("mother", "father") // Hello mother, and father. fun greetMulti(vararg args: String) { multiGreet.format(*args) } greetMulti("mother", "father") // Hello mother, and father.
raw stringsでの展開
"
3つで囲むやつです。改行などもそのまま出力できます。
フォーマットが複数ある場合は $
を使う必要があるのですが、raw stringsだとバックスラッシュでのエスケープができません。
val rawEscapedMultiGreet = """ |Hello %1\$s. |Hello %2\$s. """.trimMargin() // Unresolved reference: s // コンパイルエラー
次のように書きます。
val rawMultiGreet = """ |Hello %1${'$'}s. |Hello %2${'$'}s. """.trimMargin()
${}
のString Templatesを利用して$
に置き換えます。
参考
KotlinでString.formatを使う / Qiita
Variable number of arguments (Varargs) / Kotlin Programming Language
JavaからKotlinに変換してSupport LibraryとMockitoとRobolectricでハマった話とその解決方法
先日勤務先で開発合宿に行きました。プロダクトのKotlin化が途中だったので、仕上げてきました。
スライドでは省略した、ハマった点の詳細を共有します。
確認できるサンプルを用意してあります。サンプルの環境は次の通りです。
- Android Studio: 2.3.3
- Support Library: 25.3.1
- Kotlin: 1.1.3-2
- Mockito: 2.8.47
- Robolectric: 3.3.2
ハマった点
Non-NullにNullが入る
JavaからKotlinへは、Android Studioの機能でファイル単位で変換できます。
Javaで定義されているメソッドを呼び出す際に、次のエラーが起きてアプリがクラッシュすることがあります。
Caused by: java.lang.IllegalArgumentException: Parameter specified as non-null is null: method kotlin.jvm.internal.Intrinsics.checkParameterIsNotNull
これはNon-Nullな引数にNullが入って起きます。
自分の環境では Activity#onActivityResult
で起きました。
回避するには @Nullable
アノテーションを付けておけば、変換時にも考慮されてNullableになります。
@Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); }
- Kotlin
override fun onCreate(savedInstanceState: Bundle?) { // Nullableになってる super.onCreate(savedInstanceState) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { // dataがNullableになっていない super.onActivityResult(requestCode, resultCode, data) }
Kotlinで定義されていればどこかで不整合が起きて気づくか、上の例外は発生しません。
プロジェクト内でKotlinからJavaを呼び出している箇所があればこれは起こり得るので、注意してください。
Abstractクラスのテストで変更が反映されない
Abstractクラスを継承したクラスで、変更したプロパティが反映されないというものがありました。
次のようなクラスです。要点だけ抜き出してます。
- Abstractクラス
abstract class Ticket { abstract var memo: String ... }
- 継承したクラス
class MovieTicket(...) : Ticket() { override var memo: String = "memo" ... }
こちらはこのような結果になります。
val mock = Mockito.mock(MovieTicket::class.java) Assert.assertNull(mock.memo) // nullになる mock.memo = "test memo" Assert.assertNull(mock.memo) // 代入してもnullになる
似たようなものをJavaで書きます。
- Abstractクラス
public abstract class JTicket { @NonNull String memo; ... }
- 継承したクラス
public class JMovieTicket extends JTicket { JMovieTicket(...) { memo = "memo"; ... } ... }
こちらはこのような結果になります。
JMovieTicket mock = Mockito.mock(JMovieTicket.class); Assert.assertNull(mock.memo); // nullになる mock.memo = "test memo"; Assert.assertNotNull(mock.memo); // 代入されるのでNullにはならない
Javaは直接フィールドに、Kotlinはアクセサーを利用している違いが、この挙動の変化の理由です。
はじめに定義したKotlinをcompileしてからdecompileしてJavaへ変換してみます。便宜上KJTicketとしておきます。
- Abstractクラス
public abstract class KJTicket { public KJTicket() { } public abstract String getMemo(); public abstract void setMemo(String s); ... }
- 継承したクラス
public final class KJMovieTicket extends KJTicket { .... private String memo; public KJMovieTicket() { this(...); } public KJMovieTicket(...) { ... memo = "memo"; ... } ... public String getMemo() { return memo; } ... public void setMemo(String s) { Intrinsics.checkParameterIsNotNull(s, "<set-?>"); memo = s; } }
KJTicketクラスにはフィールドがありません。そして、継承したKJMovieTicketにフィールドが生成されていますが、privateになっています。なので、フィールドには、メソッドでアクセスしていることになります。
このフィールドはBacking fieldsと呼ばれるもので、自動で生成されるものです。 Backing fieldsにアクセスするメソッドをアクセサーと言います。
このクラスを使うとこのような結果となります。
KJMovieTicket mock = Mockito.mock(KJMovieTicket.class); Assert.assertNull(mock.getMemo()); // nullになる mock.setMemo("test memo"); Assert.assertNull(mock.getMemo()); // 代入してもnullになる
無事Kotlinのときと同じ結果になりました。
MockitoのMockは、実際のメソッドを呼ばないので、アクセサーがうまく機能していません。
なので、MockをMockito.CALLS_REAL_METHODS
オプションを付けて生成することで、実際のメソッドを呼ぶようにしてあげます。
val mockWithOption = Mockito.mock(MovieTicket::class.java, Mockito.CALLS_REAL_METHODS) Assert.assertNull(mockWithOption.memo) // nullになる val memo = "test memo" mockWithOption.memo = memo Assert.assertEquals(memo, mockWithOption.memo) // test memoが代入されてる
これでうまくいきます。
もっとシンプルに解決したい場合はSpyすれば良いのですが、インスタンスを作れない制約が既存コードにあったので、このような解決方法を取りました。
見出しでAbstractクラスのテストと書いたのですが、これは普通のクラスでもKotlinなら起きます。
- 普通のクラス
class CouponTicket(var memo: String = "coupon")
- Mockを用いたテスト
val mock = Mockito.mock(CouponTicket::class.java) Assert.assertNull(mock.memo) // nullになる mock.memo = "test memo" Assert.assertNull(mock.memo) // 代入してもnullになる
Abstractクラスでもmockを作れます。
Abstractクラスでは、Mockito.CALLS_REAL_METHODS
オプションを付けてもnull
になります。まだ実装されてないので、何もしないメソッドが呼ばれるだけだからです。
val mockWithOption = Mockito.mock(Ticket::class.java, Mockito.CALLS_REAL_METHODS) Assert.assertNull(mockWithOption.memo) // nullになる val memo = "test memo" mockWithOption.memo = memo Assert.assertNull(mockWithOption.memo) // nullになる
Mockito.when
を使うことでメソッドをmockできます。
val mock = Mockito.mock(Ticket::class.java) Assert.assertNull(mock.memo) // nullになる val memo = "test memo" Mockito.`when`(mock.memo).thenReturn(memo) // メソッドの返り値を置き換えている Assert.assertEquals(memo, mock.memo)
@Jvmアノテーションを消せない
フルKotlinなら、Jvm系のアノテーションが削除できると思って、全て削除したところ一部期待通りの挙動にならない部分がありました。
Androidに依存する部分のtestを、ReobolectricのShadowを使って置き換えていた部分で期待通りに動きませんでした。
- Device.kt
object Device { /** * 端末の日付の設定を自動にしているかどうか。 * */ fun enabledAutoTime(context: Context): Boolean { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { return Settings.Global.getInt(context.contentResolver, Settings.Global.AUTO_TIME) > 0 } else { return Settings.System.getInt(context.contentResolver, Settings.System.AUTO_TIME) > 0 } } catch (e: Settings.SettingNotFoundException) { return false } } }
Device#enabledAutoTime
は、Roblectricで呼ぶとSettings.SettingNotFoundException
が発生して常にfalseになってしまうので、Shadowを作成して挙動を制御していました。
ObjectのままShadowを作成するとpublicなコンストラクタがないのでエラーになります。
Caused by: java.lang.RuntimeException: Could not instantiate shadow, missing public empty constructor. at org.robolectric.internal.bytecode.ShadowWrangler.getShadowCreator(ShadowWrangler.java:385) at org.robolectric.internal.bytecode.RobolectricInternals.getShadowCreator(RobolectricInternals.java:34) at org.robolectric.internal.bytecode.InvokeDynamicSupport.bindInitCallSite(InvokeDynamicSupport.java:115) at org.robolectric.internal.bytecode.InvokeDynamicSupport.bootstrapInit(InvokeDynamicSupport.java:53) at java.lang.invoke.CallSite.makeSite(CallSite.java:283) at java.lang.invoke.MethodHandleNatives.linkCallSiteImpl(MethodHandleNatives.java:307) at java.lang.invoke.MethodHandleNatives.linkCallSite(MethodHandleNatives.java:297) at com.sakebook.android.sample.cornercasetestsample.Device.$$robo$init(Device.kt) at com.sakebook.android.sample.cornercasetestsample.Device.<init>(Device.kt)
そのため、次のようなShadowを作成しました。
- ShadowDevice.kt
@Implements(Device::class) class ShadowDevice { companion object { @Implementation fun enabledAutoTime(context: Context): Boolean { return true } } }
こちらも、KotlinからJavaに変換してみました。便宜上KJDeviceとします。
- KJDevice.java
public final class KJDevice { private KJDevice() { } static { new KJDevice(); } // public static final KJDevice INSTANCE = (KJDevice)this; public static final KJDevice INSTANCE = new KJDevice(); public final boolean enabledAutoTime(Context context) { Intrinsics.checkParameterIsNotNull(context, "context"); try { if(android.os.Build.VERSION.SDK_INT >= 17) { return android.provider.Settings.Global.getInt(context.getContentResolver(), "auto_time") > 0; } else { return Settings.System.getInt(context.getContentResolver(), "auto_time") > 0; } } catch (Settings.SettingNotFoundException e) { return false; } } }
コンストラクタがPrivateになっています。 コメントアウトしているところは表現できなかったのでコメントアウト直後のものに変えてます。
こちらもJavaから呼び出すとわかるのですが、 enabledAutoTime
を呼び出すとこうなります。
KJDevice#INSTANCE#enabledAutoTime(context)
staticメソッドになっていないので、INSTANCE
越しに呼び出すことになります。
ObjectはそのままShadowを作成できないので、classでShadowを作成する必要があります。その際、 @JvmStatic
アノテーション を付けないと、Objectで定義しているメソッドと同じSyntaxにならないので、Shadowでの置き換えがされません。
利用しているクラスも、ShadowクラスもKotlinで書いていても、こちらは`@JvmStaticアノテーションが必要になります。
まとめ
長々と書きましたが、ハマった点の要点は以下です。
- KotlinからJavaを呼び出している箇所でNon-NullにNullが入る可能性があるので、可能ならば
@Nullable
アノテーションを付けてからJava -> Kotlin変換をする - Kotlinはプロパティのアクセスはメソッドを利用するので、Mockするときは
Mockito.CALLS_REAL_METHODS
を使う - ObjectのShadowを作るときはClassで作成し、Syntaxが同じになるように
@JvmStatic
アノテーションをつける
Javaからどう見えるかというところを意識する必要があったり、言語仕様的な部分を意識する必要がたまにあるのですが、最終的に腑に落ちたので良いハマり方をしました。
参考
海で開発!? 2泊3日で伊豆に開発合宿行ってきました! / JX通信社 エンジニアブログ
Null Safety / Kotlin Programming Language
KotlinコードをJavaコードに変換してみた / アクトインディ技師部隊報告書
Working with the Command Line Compiler / Kotlin Programming Language
apkをコマンド一つでjavaにデコンパイルする / Qiita
Properties and Fields / Kotlin Programming Language
BITRISEでPrivateなDockerイメージを利用してビルドする(Push時のみ)
自分のDockerイメージを作成したので、CIサービスでも利用して、環境を持ち運べるようにしたいなと思いました。
BITRISEがモバイルアプリのCIサービスとして良いという話を見かけたので、BITRISE上で自分のDockerイメージを利用してビルドするのを試しました。
タイトルにも書いてあるように、一部制約があります。
BITRISE
CIサービスの一つです。Werckerと比較してですが、workflowが扱いやすいです。GUIのエディターがあるのと、やりたいことがstepという単位でコンポーネント化されてるので、追加・削除が容易です。
ローカル環境で試すためのBITRISE CLIというものも用意されていて、非常に便利でした。
今回は共有のためにbitrise.ymlをコミットしてますが、BITRISE上でいじるものなのでコード管理する必要はないです。BITRISE CLIを利用してローカルで試す際はbitrise.ymlは必要です。しかし、BITRISE上だと容易に変更出来すぎてしまうので、コード管理しておいてもいいかなとも思いました。bitrise.ymlを管理していても、BITRISE上では無視されます。
利用しているbitrise.ymlはこちらです
Dockerイメージを取得して任意のコマンドを実行する
scriptで記述します。
- registryにログイン
- イメージをpull
- コマンドを実行
これらを行います。
registryにログイン
docker login
をします。
今回はDockerHubだったので指定はしてないですが、任意のregistryを指定して、ログインします。
$ docker login -u $USERNAME -p $PASSWORD
privateな変数はBITRISEのSecret Environment Variables
で指定します。BITRISE CLIで試すときは、 .bitrise.secrets.yml
を作成して、そちらに定義します。
- .bitrise.secrets.yml
envs: - USERNAME: xxxxxxxx - PASSWORD: xxxxxxxx - DANGER_GITHUB_API_TOKEN: xxxxxxxxxxxxx
イメージをpull
無事ログインできたらpullします。
イメージのcacheは、現在対応していないようです(一部イメージのみ対応。後述)。
docker pull $DOCKERHUB_IMG_ID
$DOCKERHUB_IMG_ID
は自分で App Environment Variables
に定義しています。
envs: - DOCKERHUB_IMG_ID: sakebook/docker-android-alpine:25.0.3_3
コマンドを実行
コンテナを残す必要はないのでrmオプションを付けてrunします。
BITRISE上でDockerを使うと、 /root
はマウントできません。マウント可能なのは /bitrise
以下だけです。
なので、Gradle本体や依存関係をcacheさせるには、Gradleを実行する位置を調整する必要があります。
マウント位置を、Dockerのユーザホームに指定します。
そして、ワーキングディレクトリは指定がなければ /
になってしまうので、マウントした位置に移動してソースを見つけられるようにします。
rm -rf local.properties
は、ローカルで実行したときにDocker上のANDROID_HOMEが上書きされてしまうのを防ぐために削除します。Android Studioを立ち上げ直すと再び作成されるので心配ありません。
もろもろを合わせると、実行部分は次のようになります。
- script@1.1.4: title: run test use docker inputs: - content: |- rm -rf local.properties docker run --rm \ -v $BITRISE_SOURCE_DIR:$DOCKER_HOME \ -w $DOCKER_HOME \ -e BITRISE_IO=true \ -e GIT_REPOSITORY_URL=$GIT_REPOSITORY_URL \ -e BITRISE_PULL_REQUEST=$BITRISE_PULL_REQUEST \ -e DANGER_GITHUB_API_TOKEN=$DANGER_GITHUB_API_TOKEN \ $DOCKERHUB_IMG_ID bash -c \ " bundle install bundle exec danger ./gradlew :multilinedivider:testDebug -PdisablePreDex "
$DOCKER_HOMEは利用するDockerイメージに応じて変えてください。自分のDockerイメージではユーザを作成していないので/root
にしています。
envs: - GRADLEW_PATH: "./gradlew" - GRADLE_BUILD_FILE_PATH: build.gradle - DOCKER_HOME: "/root"
注意点として、Docker上でビルドすると、環境変数を引き継いであげる必要があります。
CIだと、Pull Requrst(PR)なのかPUSHなのかまたはどのブランチからなのか、などトリガーに応じて処理を分けることがあります。
自前のDocker上で実行してしまうと、それらの環境変数が適応されないので、eオプションで適宜必要なものを渡してあげます。
先程の例だと、BITRISE上で、PRをトリガーに実行されたということを渡しています。
Dangerを使わなかったり、PRと認識させたくない場合、一行しか実行しなくて良い場合などはもっとシンプルになります。
制約
BITRISEだと、PRがトリガーのビルドの場合 Secret Environment Variables
が利用できません。
なので、PrivateなDockerイメージを利用しようとした場合、USERNAMEとPASSWORDをApp Environment Variables
に定義する必要があります。
これについては議論があるようです。自分は見当違いな質問を投げたときに教えてもらいました。
PRで利用できてしまうと、悪意のあるユーザにSecret Environment Variables
が漏洩するからです。
Gradle本体と依存関係のcache
cacheするにはBITRISEのCache:Push
stepを使用します。
docker run実行時に変更したpathを指定することでcacheすることができます。
$HOME
も追加しているのは、dockerを利用しない場合のためです。
合わせて不要なものも除外しておきます。
- cache-push@1.1.3: inputs: - cache_paths: |- $BITRISE_SOURCE_DIR/.gradle $BITRISE_SOURCE_DIR/.m2 $HOME/.gradle $HOME/.m2 - ignore_check_on_paths: |- $HOME/.gradle/caches/*.lock $HOME/.gradle/*.bin $BITRISE_SOURCE_DIR/.gradle/*.lock $BITRISE_SOURCE_DIR./.gradle/*.bin
Dockerイメージのcache
Dockerイメージを取得する部分を速くできないか検証しました。
docker save
でpullしたDockerイメージを保持し、次回以降はdocker load
で前回利用したDockerイメージを再利用というのを試しました。
どちらもGradle周りはcache出来ている状態です。
docker load
を使うと、一応、USERNAMEとPASSWORDなしでPrivateなDockerイメージをPRで利用可能にすることもできます。
- Dockerイメージのcacheなし
- Dockerイメージのcacheあり
思ったような結果にはなりませんでした。 Dockerイメージを取得する部分も大差なく、むしろDockerイメージをcacheする部分で時間がかかってしまい、結果的に遅くなってしましました。
これはBITRISEのcacheの仕組みが、特定のディレクトリを使いまわすような仕組みではなく、ファイルをアップロード & ダウンロードする仕組みになっているからです。
また、Pull Requestのときはcacheをアップロードしないです。cacheのダウンロードは行われます。
一部イメージだと可能
BITRISEが提供しているDockerイメージだと、CIの実行環境に既にcacheされているので高速化できます。
そして自前のDockerイメージを利用するときは、BaseのDockerイメージにBITRISEが提供しているDockerイメージを使うことが推奨されています。
- Android関連のツールが既にインストールされている
- ディレクトリ構成がBITRISEで利用するように出来上がっている
- 必要な環境変数がで定義されている
- BaseにするDockerイメージがcacheされている
という利点があるためです。
まとめ
今回自前のDockerイメージを利用しましたが、Ubuntuのイメージが用意されていてビルドもできるので、BITRISEで自前のイメージを使う旨味はないと思いました。どうしてもカスタマイズしないとできないことがある場合に使うほうが良いです。
あと、Secret Environment Variables
が利用できないのが辛いです。Dangerも利用しようとしてたのですが、この制限から一旦利用をやめました。
と言いつつ、自分はイメージを育てていきたいので、Pushのときは利用して、PRのときは利用しない(できない)ようにします。
参考
Bitrise - Mobile Continuous Integration and Delivery - iOS & Android Build Automation / Bitrise
Docker support on bitrise.io / Bitrise DevCenter
How to use your own Docker image for your builds / Bitrise Discussions
Docker login failed, only PR trigger / Bitrise Discussions
How to cache Gradle dependencies / Bitrise Discussions
About caching / Bitrise DevCenter
Using bitrise.io custom docker image option / Bitrise DevCenter