メモ2ブログ

メモtoウェブログ。旧ブログはこちら。 http://sakebook.blogspot.jp/

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

String Templates / 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

github.com

ハマった点

  • Non-NullにNullが入る
  • Abstractクラスのテストで変更が反映されない
  • @Jvmアノテーションを消せない

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とします。

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

心地良すぎるモックライブラリ Mockito 〜その3〜 / A Memorandum

Robolectric 3.0基本のキ / Qiita

BITRISEでPrivateなDockerイメージを利用してビルドする(Push時のみ)

自分のDockerイメージを作成したので、CIサービスでも利用して、環境を持ち運べるようにしたいなと思いました。

sakebook.hatenablog.com

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はこちらです

github.com

Dockerイメージを取得して任意のコマンドを実行する

scriptで記述します。

  1. registryにログイン
  2. イメージをpull
  3. コマンドを実行

これらを行います。

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なし

f:id:sakebook:20170808230402p:plain

  • Dockerイメージのcacheあり

f:id:sakebook:20170808230349p:plain

思ったような結果にはなりませんでした。 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

Bitrise CLI / 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

Dockerコマンドメモ / Qiita

About caching / Bitrise DevCenter

Using bitrise.io custom docker image option / Bitrise DevCenter