Quantcast
Channel: nashcft's blog
Viewing all 97 articles
Browse latest View live

Kotlin で『テスト駆動開発』を進める (第8章 - 第11章)

$
0
0

前回

今回は11章まで進める。前回からの Money, Dollar, Francにまつわるリファクタリングの続きで、今回分で一区切りがつく内容となっている。

現在のコードは以下の通り:

MoneyTest.kt

class MoneyTest {

    @Testfun testMultiplication() {
        val five = Dollar(5)
        assertEquals(Dollar(10), five.times(2))
        assertEquals(Dollar(15), five.times(3))
    }

    @Testfun testEquality() {
        assertTrue(Dollar(5) == Dollar(5))
        assertFalse(Dollar(5) == Dollar(6))

        assertTrue(Franc(5) == Franc(5))
        assertFalse(Franc(5) == Franc(6))

        assertFalse(Dollar(5).equals(Franc(5)))
    }

    @Testfun testFrancMultiplication() {
        val five = Franc(5)
        assertEquals(Franc(10), five.times(2))
        assertEquals(Franc(15), five.times(3))
    }
}

Money.kt

dataclass Dollar(privateval amount: Int) : Money(amount)
dataclass Franc(privateval amount: Int) : Money(amount)

sealedclass Money(privateval amount: Int) {

    fun times(multiplier: Int) = when (this) {
        is Dollar -> Dollar(amount * multiplier)
        is Franc -> Franc(amount * multiplier)
    }
}

第8章 実装を隠す

この章ではまず times()関数を纏める準備として返却値の型を Moneyに変更しているが、これは既に第6章で実装はともかく Moneytimes()関数を移すところまで達成している。

次は Moneyのサブクラスのインスタンスを生成する時に直接それらを参照しないよう、それぞれの static factory method を作成する。Kotlin では Javaのようにクラスに static method を設けることはできないが、同じような挙動をする関数を作る方法はいくつか用意されている*1。今回は companion objectを使用する方法で実装する (以下のコードは Money内部のみを抜粋したもの)。

sealedclass Money(privateval amount: Int) {

    fun times(multiplier: Int) = when (this) {
        is Dollar -> Dollar(amount * multiplier)
        is Franc -> Franc(amount * multiplier)
    }

    companionobject {
        fun dollar(amount: Int): Money = Money.Dollar(amount)
        fun franc(amount: Int): Money = Money.Franc(amount)
    }
}

テストの方は直接 DollarFrancのコンストラクタを呼んでいた部分が factory method に置き換わっただけなので割愛する。

この変更で直接 Moneyのサブクラスを呼ぶ必要がなくなり、これらを削除する準備が進んだ。

TODO リストに1つ項目を追加して次の章に進む:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 * 2 = $10
  • [x] amount を private に
  • [x] Dollar の副作用をどうする?
  • [ ] Money の丸め処理をどうする?
  • [x] equals()
  • [x] hashCode()
  • [x] null との等値性比較
  • [x] 他のオブジェクトとの等値性比較
  • [x] 5CHF * 2 = 10CHF
  • [ ] Dollar と Franc の重複
  • [x] equals の一般化
  • [ ] times の一般化 -> WIP
  • [x] Franc と Dollar を比較する
  • [ ] 通過の概念
  • [ ] testFrancMultiplicationを削除する?

第9章 歩幅の調整

この章ではこれまでサブクラスで表していた通貨の概念を別の方法で表現することで、サブクラスを消す準備を万全にしていく。

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 * 2 = $10
  • [x] amount を private に
  • [x] Dollar の副作用をどうする?
  • [ ] Money の丸め処理をどうする?
  • [x] equals()
  • [x] hashCode()
  • [x] null との等値性比較
  • [x] 他のオブジェクトとの等値性比較
  • [x] 5CHF * 2 = 10CHF
  • [ ] Dollar と Franc の重複
  • [x] equals の一般化
  • [ ] times の一般化 -> WIP
  • [x] Franc と Dollar を比較する
  • [ ] 通過の概念
  • [ ] testFrancMultiplicationを削除する?

この章は同じことを表現するのにやや寄り道っぽいことをしていて既読組からしたら冗長に感じるかもしれないが、「過程を眺める」のがこのシリーズの動機なので、章のタイトルになっている「歩幅の調整」の様子も残しておきたい。

まずはテストの追加。

class MoneyTest {

    // ...@Testfun testCurrency() {
        assertEquals("USD", dollar(1).currency())
        assertEquals("CHF", franc(1).currency())
    }

}

メソッド呼び出しでどの通貨かわかるようにしたいようなので、Moneycurrency()関数を追加する。書籍の方ではサブクラスにも実装を追加しているが、私の実装では sealed class と when式によって Moneyクラス内の変更だけで済む。

sealedclass Money(privateval amount: Int) {

    fun currency() = when (this) {
        is Dollar ->"USD"is Franc ->"CHF"
    }

    // ...
}

実装した後、メソッドじゃなくてフィールド変数でよくない? となり、書籍も実際そういう流れなのでそのようにしていく。これは実際 Javaのコードでは currency()は消えていないのだけど、これが実質 currencyをそのまま返す getter になってて、フィールドの値をそのまま返す getter だけ生えてるって valで定義したフィールドそのものじゃんとなり、 Kotlin 的にはフィールドにアクセスする記法が普通っぽいのでそれに従うという判断による。カスタム getter/setter を作っても使い方はフィールドアクセスと一緒だし。

まずは data class の方から。

dataclass Dollar(privateval amount: Int,
                  val currency: String = "USD") : Money(amount)
dataclass Franc(privateval amount: Int, 
                 val currency: String = "CHF") : Money(amount)

Kotlin では関数の引数やコンストラクタに書かれたプロパティにデフォルト値を設定できるので上記のような書き方ができる。これだけだと factory method が Money型として返している都合上使っている側はキャストしないと currencyを参照できないので Moneyにも currencyを生やす。

dataclass Dollar(privateval amount: Int,
                  overrideval currency: String = "USD") : Money(amount, currency)
dataclass Franc(privateval amount: Int, 
                 overrideval currency: String = "CHF") : Money(amount, currency)

sealedclass Money(privateval amount: Int, openval currency: String) {
    // ...
}

コンストラクタの中では abstractにはできないので openにして、サブクラスの currencyには overrideをつけ、スーパークラスに渡すように変更した。わざわざ override させる意義はないのだけど、スーパークラスのフィールドが private じゃないのでサブクラスの同名フィールドはアクセス範囲を狭められないし、かといって使わないからってサブクラスのフィールド名を雑なものにするのもちょっと... という気持ちもあり、まああとで消すのだし今はいいかということでこのまま進める。

次は通貨の種類を表す文字列を 外部から渡すようにしてコンストラクタの差異 (= デフォルト引数) を無くそうというもの。

dataclass Dollar(privateval amount: Int,
                  overrideval currency: String) : Money(amount, currency)
dataclass Franc(privateval amount: Int, 
                 overrideval currency: String) : Money(amount, currency)

sealedclass Money(privateval amount: Int, openval currency: String) {

    // ...companionobject {
        fun dollar(amount: Int): Money = Money.Dollar(amount, "USD")
        fun franc(amount: Int): Money = Money.Franc(amount, "CHF")
    }
}

これによってサブクラスの currencyに対するデフォルト値がなくなったため times()関数内でも何か値をあげるようにしないといけなくなり、それを回避するために書籍に倣って factory method を呼ぶように変更する

sealedclass Money(privateval amount: Int, openval currency: String) {

    fun times(multiplier: Int) = when (this) {
        is Dollar -> dollar(amount * multiplier)
        is Franc -> franc(amount * multiplier)
    }

    // ...
}

これでこの章でやることは終わり。途中 Money.kt の中身は変更に関係ない部分を省略して記述したため、小さいファイルとはいえ一応全体を載せておく。

dataclass Dollar(privateval amount: Int,
                  overrideval currency: String) : Money(amount, currency)
dataclass Franc(privateval amount: Int, 
                overrideval currency: String) : Money(amount, currency)

sealedclass Money(privateval amount: Int, openval currency: String) {

    fun times(multiplier: Int) = when (this) {
        is Dollar -> dollar(amount * multiplier)
        is Franc -> franc(amount * multiplier)
    }

    companionobject {
        fun dollar(amount: Int): Money = Money.Dollar(amount, "USD")
        fun franc(amount: Int): Money = Money.Franc(amount, "CHF")
    }
}

第10章 テストに聞いてみる

この章の目標は times()メソッドを Moneyクラス内に引き上げることなのだが、ご存知の通り既に Moneyにいるので、やることは times()の実装を修正するだけである。具体的には factory method を呼ぶようにしたのをやめて、コンストラクタに自身の currencyを与えるようにする。

sealedclass Money(privateval amount: Int, openval currency: String) {

    fun times(multiplier: Int) = when (this) {
        is Dollar -> Dollar(amount * multiplier, currency)
        is Franc -> Franc(amount * multiplier, currency)
    }

    // ...
}

これで times()の分岐後の処理が同じ形になったので、分岐の意味も殆ど無くなったし分岐を消して Moneyを返すようにする... と言いたいところだが、 Moneyは sealed class なので直接インスタンス化できない。ここでは一度見送って、次の章に進む。
ところでこの章で追加される予定だったテストケースは equals()を自分で実装してないしどうしようもないのでパスした。

第11章 不要になったら消す

もう DollarFrancは必要なくなったので削除し、直接 Moneyを使えるようにする。Dollarたちと同じように使えるようにするため、 Moneyは sealed class から data class へ変更する。

dataclass Money(privateval amount: Int, val currency: String) {

    fun times(multiplier: Int) = Money(amount * multiplier, currency)

    companionobject {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

とてもスッキリした。あとは不要になっているテストを削除する。対象は

  • testEquality()内で Franc同士の比較をしていた assertion (USD, CHF を表現するクラスが同一になったので重複する検査となったため)
  • testFrancMultiplication() (これも重複)

そして USD と CHF の比較が失敗するという assertion で何も書き足さなくても ==が使えるようになったのでそのように変更する。

これで Moneyリファクタリングが終わり、本題である通貨の足し算に進めるようになった。

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 * 2 = $10
  • [x] amount を private に
  • [x] Dollar の副作用をどうする?
  • [ ] Money の丸め処理をどうする?
  • [x] equals()
  • [x] hashCode()
  • [x] null との等値性比較
  • [x] 他のオブジェクトとの等値性比較
  • [x] 5CHF * 2 = 10CHF
  • [x] Dollar と Franc の重複
  • [x] equals の一般化
  • [x] times の一般化
  • [x] Franc と Dollar を比較する
  • [x] 通過の概念
  • [x] testFrancMultiplicationを削除する?

ここまでのまとめ

第11章終了時点でのコード:

MoneyTest.kt

class MoneyTest {

    @Testfun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five.times(2))
        assertEquals(dollar(15), five.times(3))
    }

    @Testfun testEquality() {
        assertTrue(dollar(5) == dollar(5))
        assertFalse(dollar(5) == dollar(6))

        assertFalse(dollar(5) == franc(5))
    }

    @Testfun testCurrency() {
        assertEquals("USD", dollar(1).currency)
        assertEquals("CHF", franc(1).currency)
    }
}

Money.kt

dataclass Money(privateval amount: Int, val currency: String) {

    fun times(multiplier: Int) = Money(amount * multiplier, currency)

    companionobject {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

ここまで Moneyにまつわるリファクタリングを続けてきたが、実装の過程においては「times()の一般化」は sealed class で実装したことによって殆どやることがなかった点と、 currencyの実装周りで Java版とは異なる苦労をしたのが私の中で印象的だった一方、出来上がったコードは第4章時点のものに近いもの、つまり Java版とあまり変わらないものに落ち着いているのも興味深い。次回以降も極力 Kotlin の機能を使うようにして Java版との違いを見ていくようになっているのでお楽しみに。
ところで Elm 版の記事では今回追加した通貨の概念を型として表現するように変更を加えており、 Kotlin*2では enum class を用いて同様の表現ができるのだが、写経をやってた当時色々考えて結局最後に変更するまで文字列のままでいた。これは本筋とは関係ないので前回の Moneyを sealed class で実装した時のこととまとめて一連の記事の最後に振り返りみたいな感じで触れるつもり。まあ同じ理由だし、 sealed class はクラスの enumのようなものだし。

*1:この記事で採用している companion object の他に package level functionとして定義する方法もある

*2:というか Javaもできるのだが


最近書いてないやつ

$
0
0

別段忙しくてニャーンであるとかそういう状態ではないのだけど全然日記的なもの書けてなかったり GitHubガーデニングも真っ白殺風景になってたりしていますが私は元気です。

何していたかというと会社では毎日無理ない程度に進捗を出してるだけなのに帰宅すると疲れ果ててひたすら寝るということをしていたり起きている間は ÉKRITS Books I読んでたりしていて、先月色々あった分の疲れを身体が認識し始めたのかなーと適当に考えて静かに過ごしているというかそうしかできないというか。
そういえば Packt Publishing がセールで電子書籍を $5 で売ってて、まだやってるっぽいので気になるけど Packt の本のクオリティに不安があって躊躇してる的なのがあったらいいタイミングだと思う。

www.packtpub.com

私はというと気がついたら11冊買ってたのでセールは恐ろしい。

Kotlin で『テスト駆動開発』を進める (第12章 - 第13章)

$
0
0

前回

前回までは通貨を表すクラスをシンプルにしていくタスクが中心だったが、今回からやっと本題である通貨の足し算に手を着け始める。

現在のコード:

MoneyTest.kt

class MoneyTest {

    @Testfun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five.times(2))
        assertEquals(dollar(15), five.times(3))
    }

    @Testfun testEquality() {
        assertTrue(dollar(5) == dollar(5))
        assertFalse(dollar(5) == dollar(6))

        assertFalse(dollar(5) == franc(5))
    }

    @Testfun testCurrency() {
        assertEquals("USD", dollar(1).currency)
        assertEquals("CHF", franc(1).currency)
    }
}

Money.kt

dataclass Money(privateval amount: Int, val currency: String) {

    fun times(multiplier: Int) = Money(amount * multiplier, currency)

    companionobject {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

第12章 設計とメタファー

まずは長くなった TODO リストから未完了で必要なタスクを取り出し、新しい TODO も追加する:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10

まずは同じ通貨の足し算から。追加するテストは以下の通り。

class MoneyTest {

    // ...@Testfun testSimpleAddition() {
        val sum = dollar(5).plus(dollar(5))
        assertEquals(dollar(10), sum)
    }
}

そして仮実装を書く。

dataclass Money(privateval amount: Int, val currency: String) {

    // ...fun plus(addend: Money) = Money(amount + addend.amount, currency)
    // ...
}

書籍ではここで多国通貨間の計算をどう表現するかという議論とメタファーに関するお話が始まり、「式 (expression)」のメタファーを採用するという結果に着地しするので、それをテストコードに反映させる。

class MoneyTest {

    // ...@Testfun testSimpleAddition() {
        val five = dollar(5)
        val sum = five.plus(five)
        val reduced = reduce(sum, "USD")
        assertEquals(dollar(10), reduced)
    }
}

書籍では reduce()を「銀行の責務」として Bankクラスを作りそこに実装するのだが、今の所持つ状態も無さそうだししばらく package-level function でいいかという判断をして様子を見ることにした。

新しいものが追加されたのでそれらを実装していく。
まずは Expressionインターフェイスとして作成。

// Expression.ktinterface Expression

Moneyplus()関数の返却値型を Expressionに変更し、 Money自身にも Expressionを実装する。

dataclass Money(privateval amount: Int, val currency: String) : Expression {

    // ...fun plus(addend: Money): Expression = Money(amount + addend.amount, currency)
    // ...
}

reduce()については、とりあえず Bank.kt ファイルを作成してそこに package-level function として定義して、テストを通すための仮実装をしておく。なお、実際に呼び出す際は reduce()だけでよいが、記事内では便宜上 Bank.reduce()と呼ぶ。

// Bank.ktfun reduce(source: Expression, to: String) = Money.Companion.dollar(10)

これで12章の内容は終わりなのだが、ちょっとだけ寄り道をしたい。
今回新しく plus()関数が追加されたが、以前から times()関数があり、そういえばこの辺は四則演算を表す関数名だということを思い出す。通貨の計算も「通貨」の概念がある以外は分量の計算だし数値と同じように演算子でできたらいいなーと思い調べてみれば Operator overloadingができるみたいなのでこのタイミングで書き換えてしまおう。Operator overloading をするには対象となる関数定義に operatorキーワードを加えるだけでよい。

dataclass Money(privateval amount: Int, val currency: String) : Expression {

    operatorfun times(multiplier: Int) = Money(amount * multiplier, currency)

    operatorfun plus(addend: Money): Expression = Money(amount + addend.amount, currency)

    // ...
}

すると現在のテストコードは以下のように書き直せる。

class MoneyTest {

    @Testfun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five * 2)
        assertEquals(dollar(15), five * 3)
    }

    // ...@Testfun testSimpleAddition() {
        val five = dollar(5)
        val sum = five + five
        val reduced = reduce(sum, "USD")
        assertEquals(dollar(10), reduced)
    }
}

...乗算をする時の左辺と右辺が違う型の値なのが見た感じ微妙かもしれないけど慣れの問題のような気もする。まあ終わった TODO にも $5 * 2 = $10とかあるし、よりこれに近い記述ができてるからよいということにする。ところで現状だと乗算の順序が固定されてしまうので、不満があれば Intに対して以下のように extension functionを追加するということができる。この写経では使わないけど。

operatorfunInt.times(money: Money) = money.times(this)

第13章 実装を導くテスト

前章で実装した plus()の重複を取り除くために先へ進める。まずは TODO の追加から:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Moneyを返す

Sumクラスを追加するためのテストを書く。とりあえずキャストなど含め書籍のコードそのままっぽく。

class MoneyTest {

    // ...@Testfun testPlusReturnsSum() {
        val five = dollar(5)
        val result = five + five
        val sum = result as Sum
        assertEquals(five, sum.augend)
        assertEquals(five, sum.addend)
    }
}

Sumを追加する。使い方がまだはっきりしないのでとりあえず普通の class で宣言しておく。

// Sum.ktclass Sum(val augend: Money, val addend: Money) : Expression

Sumを返すように plus()の実装を修正する。

dataclass Money(privateval amount: Int, val currency: String) {

    operatorfun times(multiplier: Int) = Money(amount * multiplier, currency)

    operatorfun plus(addend: Money): Expression = Sum(this, addend)

    // ...
}

Sumの準備ができたので reduce()リファクタリングを始める。まずは現状の reduce()では失敗するようなテストを追加する。

class MoneyTest {

    // ...@Testfun testReduceSum() {
        val sum = Sum(dollar(3), dollar(4))
        val result = reduce(sum, "USD")
        assertEquals(dollar(7), result)
    }
}

Bank.reduce()を修正するが、記事が長くなってきたので書籍では2段階かけてるところを一気に進める。

fun reduce(source: Expression, to: String): Money {
    val sum = source as Sum
    return sum.reduce(to)
}

Sum.reduce()の追加:

class Sum(val augend: Money, val addend: Money) : Expression {

   fun reduce(to: String): Money {
        val amount = augend.amount + addend.amount
        return Money(amount, to)
    }
}

外部から参照されるようになったので Money.amountprivateを外す:

dataclass Money(val amount: Int, val currency: String) : Expression {

    // ...
}

ここで TODO を1つ追加:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Moneyを返す
  • [ ] Bank.reduce(Money)

この後も一気に進める。やることは Bank.reduce()内で型キャストを行わなくて済むように、Expressionインターフェイスreduce()を定義することだ。

class MoneyTest {

    // ...@Testfun testReduceMoney() {
        val result = reduce(dollar(1), "USD")
        assertEquals(dollar(1), result)
    }
}
interface Expression {

    fun reduce(to: String): Money
}
dataclass Money(val amount: Int, val currency: String) : Expression {

    // ...overridefun reduce(to: String): Money = this// ...
}
class Sum(val augend: Money, val addend: Money) : Expression {

  overridefun reduce(to: String): Money {
        val amount = augend.amount + addend.amount
        return Money(amount, to)
    }
}

これで Bank.reduce()では Expression.reduce()を呼ぶだけでよくなる。

fun reduce(source: Expression, to: String): Money = source.reduce(to)

これでこの章での実装は終わり。最後に TODO を消したり足したりしておく:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Moneyを返す
  • [x] Bank.reduce(Money)
  • [ ] Moneyを変換して換算を行う
  • [ ] Reduce(Bank, String)

第14章に入る前に

さて、単に写経をするだけならこれで第13章は終わりだが、一度ここで現在の実装を確認したい。

// Bank.ktfun reduce(source: Expression, to: String): Money = source.reduce(to)
// Expression.ktinterface Expression {

    fun reduce(to: String): Money
}
// Money.ktdataclass Money(val amount: Int, val currency: String) : Expression {

    operatorfun times(multiplier: Int) = Money(amount * multiplier, currency)

    operatorfun plus(addend: Money): Expression = Sum(this, addend)

    overridefun reduce(to: String): Money = thiscompanionobject {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}
// Sum.ktclass Sum(val augend: Money, val addend: Money) : Expression {

  overridefun reduce(to: String): Money {
        val amount = augend.amount + addend.amount
        return Money(amount, to)
    }
}

(テストコード省略)

この構成、多分 Java的には特に違和感のない構成なのかもしれないが、私がここまで進めた時は以下のような疑問を抱いた:

  • なんで reduce()の実処理が Expressionの実装にあるのか
    • Bankだけが処理の詳細を知っていればよいのでは?
    • Kotlin だったらこういう分岐って以前やった sealed class と whenのパターンマッチングでいい感じに1ヶ所にまとめて表現できるよね?

ということでこのシリーズでもおなじみの Elm 版を参考に以下のように書き直した。

// Bank.ktfun reduce(source: Expression, to: String) = when (source) {
    is Money -> source
    is Sum -> Money(sum(source.augend, source.addend, to), to)
}

privatefun sum(augend: Expression, addend: Expression, to: String): Int =
            reduce(augend, to).amount + reduce(addend, to).amount
// Money.ktdataclass Money(val amount: Int, val currency: String) : Expression() {

    operatorfun times(multiplier: Int) = Money(amount * multiplier, currency)

    operatorfun plus(addend: Money): Expression = Sum(this, addend)

    companionobject {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealedclass Expression {

    dataclass Sum(val augend: Money, val addend: Money) : Expression()
}

テストコードに関しては import のしかたによっては Sumを参照するところが Expression.Sumになる程度で、これはどっちで参照するようにするかはお好みなので特に変更無しということで省略。

コードだけではあんまりなので解説をすると、まず Bank.reduce()の処理の実態が MoneySumに散ってしまっているのを解決しようとして、書いてある通り Expressionを sealed class としてそれのサブクラスが持つ reduce()の実装を Bank.reduce()内で whenを使ってまとめた。 これは最後に TODO に追加した Reduce(Bank, String)のように Bankをわざわざ連れまわすこともなくなるし、 "reduce"という処理を見るときにBankだけ見れば済むのでよいというところが私には嬉しい。
この辺は手続き的な分岐じゃなくて宣言的な検査だから〜など説いて書籍の実装のような操作対象のオブジェクトに振る舞いを持たせる設計と今回のパターンマッチングを用いた設計を比較してどうのこうの書こうと思ったが、今回使ったようなレベルのパターンマッチングと if文などによる分岐の差*1とは...? となり、そもそも私がパターンマッチングについて十分な理解をしていないなあと思ったので不用意なことは書かないことにする。あえて何か書くとしたら、上で変更した後の実装の方が自分のものの整理のしかたと合ってて好ましいので可能ならばそちら側に倒す実装をするが、とはいえ Javaのようにシステム上それを安全に実装することができない環境下であればそちらの流儀に合わせるだろうなあ、という所感くらい。

ところで Expressionのサブクラスについて SumExpressionの内側で宣言しているのは、具体的なものを表す Moneyと異なり Sumは抽象的な概念で Moneyのとは異なるレイヤーにあるというか、「式」というメタファーにおける内部的な表現のように感じられたので、その微妙な違いを表現してみたかったからで、特に何か構文上の制約を利用して何かしようとしているわけではない。

Bank.reduce()Sumに対する処理と sum()関数については、これは Elm 版で書かれている実装をほぼそのまま持ってきている。

fun reduce(source: Expression, to: String) = when (source) {
    is Money -> source
    is Sum -> Money(sum(source.augend, source.addend, to), to)
}

privatefun sum(augend: Expression, addend: Expression, to: String): Int =
            reduce(augend, to).amount + reduce(addend, to).amount

reduce()Sum側の処理で呼んでいる sum()はその中で reduce()を呼んでおり、これが Moneyを返すので、返ってきた Moneyamountを足し合わせて返すことで大元の reduce()で各フィールドの amountが足し合わされた Moneyができる、という再帰っぽい流れになっている。というか実際再帰になってて、先のネタバレをすると Sumのフィールドは Money型だがこれが後に Expression型になるので Sumを抱えた Sumが投げつけられることも起こりうるのだが、そんな時でも上の実装がそのまま使えるようになっている*2。また sum()については private な package-level function なので宣言された Bank.kt の中でしか参照することができないようになっており、内部詳細に当たる箇所もきちんと隠すことができている。

この修正により1つやらなくて良くなった項目があるので TODO リストを更新する:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Moneyを返す
  • [x] Bank.reduce(Money)
  • [ ] Moneyを変換して換算を行う
  • [x] Reduce(Bank, String)

ここまでのまとめ

13章にきてあえて書籍の Javaコードとは異なる設計 (というか表現?) で実装を書くようにした。これによってこの先の変更の難易度はどのように異なってくるのかや、この設計のメリットやデメリットについて見ていくようにしたい。また12章では Bankについてクラスを作らず package-level function のみで同等のものを実装したが、これは状態を持つ必要のない機能群についてはあえて先にクラスという枠を作らずに関数の単位でポコポコ作っていって、あとで意味のあるかたまりとかでまとめておくみたいなボトムアップ的アプローチができるのかなーという風に感じた。実際どうなのだろうか。

*1:今回のように sealed class と when の組み合わせでは考慮すべき場合を型レベルで狭められるという利点はあるが

*2:と言ってもここは書籍の実装も振る舞い的には変わらないのでアドバンテージとして見られるかというとそうではない

Kotlin で『テスト駆動開発』を進める (第14章 - 第16章)

$
0
0

前回

さて Kotlin で『テスト駆動開発』を写経するシリーズも Part I が今回で終わるので一区切りとなる。前回で書籍のコードから設計方針を転換しているので、1つ1つのタスクに対してどのように方針を立て実装していくか、きちんと過程を残すように書きたいと思う。

現在のコード:

MoneyTest.kt

class MoneyTest {

    @Testfun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five * 2)
        assertEquals(dollar(15), five * 3)
    }

    @Testfun testEquality() {
        assertTrue(dollar(5) == dollar(5))
        assertFalse(dollar(5) == dollar(6))

        assertFalse(dollar(5) == franc(5))
    }

    @Testfun testCurrency() {
        assertEquals("USD", dollar(1).currency)
        assertEquals("CHF", franc(1).currency)
    }

    @Testfun testSimpleAddition() {
        val five = dollar(5)
        val sum = five + five
        val reduced = reduce(sum, "USD")
        assertEquals(dollar(10), reduced)
    }

    @Testfun testPlusReturnsSum() {
        val five = dollar(5)
        val result = five + five
        val sum = result as Sum
        assertEquals(five, sum.augend)
        assertEquals(five, sum.addend)
    }

    @Testfun testReduceSum() {
        val sum = Sum(dollar(3), dollar(4))
        val result = reduce(sum, "USD")
        assertEquals(dollar(7), result)
    }

    @Testfun testReduceMoney() {
        val result = reduce(dollar(1), "USD")
        assertEquals(dollar(1), result)
    }
}

Bank.kt

fun reduce(source: Expression, to: String) = when (source) {
    is Money -> source
    is Sum -> Money(sum(source.augend, source.addend, to), to)
}

privatefun sum(augend: Expression, addend: Expression, to: String): Int =
            reduce(augend, to).amount + reduce(addend, to).amount

Money.kt

dataclass Money(val amount: Int, val currency: String) : Expression() {

    operatorfun times(multiplier: Int) = Money(amount * multiplier, currency)

    operatorfun plus(addend: Money): Expression = Sum(this, addend)

    companionobject {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealedclass Expression {

    dataclass Sum(val augend: Money, val addend: Money) : Expression()
}

TODO リスト:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Moneyを返す
  • [x] Bank.reduce(Money)
  • [ ] Moneyを変換して換算を行う
  • [x] Reduce(Bank, String)

第14章 学習用テストと回帰テスト

この章ではまず「Moneyを変換して換算を行う」に取り組む。まずはCHF -> USDの換算に関するテストを追加。

class MoneyTest {

    // ...@Testfun testReduceMoneyDifferentCurrency() {
        addRate("CHF", "USD", 2)
        val result = bank.reduce(franc(2), "USD")
        assertEquals(dollar(1), result)
    }
}

仮実装の段階なのでとりあえず Bank.kt に addRate()を追加する。

fun addRate(from: String, to: String, rate: Int) {}

このあと書籍では換算処理の記述を Money.reduce()から始めて Bankの中に持っていくために色々するが、現在の私の実装では該当する reduce()の処理は Bank.kt の reduce()の中に存在するので、いきなり Bank.kt に rate()を作って reduce()の中で呼ぶように書くだけでよい。

fun reduce(source: Expression, to: String) = when (source) {
    is Money -> Money(source.amount / rate(source.currency, to), to)
    is Sum -> Money(sum(source.augend, source.addend, to), to)
}

privatefun rate(from: String, to: String) = if (from == "CHF"&& to == "USD")  2else1

配列の比較は省略。このあと Pairクラスの作成にかかるが Kotlin には標準で Pairを持っているのでそれを使用することにする。Pairを key, 為替レートを valueとする map を Bankに保持させることになり、ここで Bankが状態を保存しておく必要が出てきたので Bankクラスを作成し、これまで Bank.kt に定義していた関数群を Bankのメンバとして持たせるよう変更する。

class Bank {

    privateval rates: MutableMap<Pair<String, String>, Int> = HashMap()

    fun reduce(source: Expression, to: String) = when (source) {
        is Money -> Money(source.amount / rate(source.currency, to), to)
        is Expression.Sum -> Money(sum(source.augend, source.addend, to), to)
    }

    privatefun rate(from: String, to: String) = if (from == "CHF"&& to == "USD")  2else1privatefun sum(augend: Expression, addend: Expression, to: String): Int =
            reduce(augend, to).amount + reduce(addend, to).amount

    fun addRate(from: String, to: String, rate: Int) {}
}

Bankを作って関数を中に放り込んだためこれまで reduce()を読んでたところがコンパイルエラーになったので修正する。

class MoneyTest {

    // ...@Testfun testSimpleAddition() {
        val five = dollar(5)
        val sum = five + dollar(5)
        val reduced = Bank().reduce(sum, "USD")
        assertEquals(dollar(10), reduced)
    }

    // ...@Testfun testReduceSum() {
        val sum = Expression.Sum(dollar(3), dollar(4))
        val result = Bank().reduce(sum, "USD")
        assertEquals(dollar(7), result)
    }

    @Testfun testReduceMoney() {
        val result = Bank().reduce(dollar(1), "USD")
        assertEquals(dollar(1), result)
    }

    @Testfun testReduceMoneyDifferentCurrency() {
        val bank = Bank()
        bank.addRate("CHF", "USD", 2)
        val result = bank.reduce(franc(2), "USD")
        assertEquals(dollar(1), result)
    }
}

テストが通るか確認をして、addRate()で実際に為替レートを格納する処理、および rate()で為替レートを取得を実装する。

class Bank {

    // ...privatefun rate(from: String, to: String) = rates[Pair(from, to)]
                    ?: throw IllegalArgumentException("Unregistered rate: $from to $to")

    // ...fun addRate(from: String, to: String, rate: Int) {
        rates.put(Pair(from, to), rate)
    }
}

Map.get()の返却値は V? (今回の場合 Int?) なので、為替レートが未登録の場合は例外を投げるか rate()rate(): Int?として定義するか適当なデフォルト値を返すがということになるが、内部処理で使用しているのでここで nullを返されても困るし、例外設計は写経の本筋と離れるので今回は例外を投げるということにしておく。ここでテストを実行すると testReduceMoney()がレッドになる。同じ通貨への為替レートを返さないといけないので、回帰テストを追加してから対応する。

class MoneyTest {

    // ...@Testfun testIdentityRate() {
        assertEquals(1, Bank().rate("USD", "USD"))
    }
}
class Bank {

    // ...fun rate(from: String, to: String) =
            if (from == to) 1else rates[Pair(from, to)]
                    ?: throw IllegalArgumentException("Unregistered rate: $from to $to")

    // ...
}

これで全テストがグリーンに戻り、14章の内容は終了。TODO リストを更新しておく:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 + $5 = $10
  • [ ] $5 + $5Moneyを返す
  • [x] Bank.reduce(Money)
  • [x] Moneyを変換して換算を行う
  • [x] Reduce(Bank, String)

第15章 テスト任せとコンパイラ任せ

この章で Part I の本題である $5 + 10CHF = $10に着手することになる。

class MoneyTest {

    // ...@Testfun testMixedAddition() {
        val fiveBucks: Expression = dollar(5)
        val tenFrancs: Expression = franc(10)
        val bank = Bank()
        bank.addRate("CHF", "USD", 2)
        val result = bank.reduce(fiveBucks + tenFrancs, "USD")
        assertEquals(dollar(10), result)
    }
}

$5 と 10CHF を Expressionとして受けているためコンパイルエラーになる。書籍の次ステップの通り一旦これらを Moneyで受けるようにするとコンパイルが通りテストが... グリーンになる。これは第13章で設計を転換した時に Bank.reduce()の実装を Bank.sum()を介して再帰的に書いていたためで、実はこのあとの書籍の対応と同じだったのだ。

そういうわけで先ほどやろうとした Expression.plusの実現を進める。まずはテストケースを章の最初に示した状態に戻し、Sumのフィールドと Money.times()の返却値の型を Expressionにする。

dataclass Money(val amount: Int, val currency: String) : Expression() {

    operatorfun times(multiplier: Int): Expression = Money(amount * multiplier, currency)

    operatorfun plus(addend: Money): Expression = Sum(this, addend)

    companionobject {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealedclass Expression {

    dataclass Sum(val augend: Expression, val addend: Expression) : Expression()
}

あとは Expression自身が plus()を持っていればよいので、Moneyからそのまま連れてくる。

dataclass Money(val amount: Int, val currency: String) : Expression() {

    operatorfun times(multiplier: Int): Expression = Money(amount * multiplier, currency)

    companionobject {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealedclass Expression {

    operatorfun plus(addend: Money): Expression = Sum(this, addend)

    dataclass Sum(val augend: Expression, val addend: Expression) : Expression()
}

最後に TODO リストを更新しておしまい:

  • [x] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 + $5 = $10
  • [ ] $5 + $5Moneyを返す
  • [x] Bank.reduce(Money)
  • [x] Moneyを変換して換算を行う
  • [x] Reduce(Bank, String)
  • [x] Sum.plus
  • [ ] Expression.times

更新内容をみてわかるが、 Expressionplus()を持ってきた時点で Sumもこの関数を使えるようになっているので完了してしまっている。

第16章 将来の読み手を考えたテスト

さて前章で Sum.plusの実装も終わったことにしているが、本当にそうなのか? 本来 Sum.plusを実装するはずだったこの章で追加されるテストで確認をする。

class MoneyTest {

    // ...@Testfun testSumPlusMoney() {
        val fiveBucks: Expression = dollar(5)
        val tenFrancs: Expression = franc(10)
        val bank = Bank()
        bank.addRate("CHF", "USD", 2)
        val sum = Expression.Sum(fiveBucks, tenFrancs) + fiveBucks
        val result = bank.reduce(sum, "USD")
        assertEquals(dollar(15), result)
    }
}

きちんと通った。では次は Expression.timesの実装をする。

class MoneyTest {

    // ...@Testfun testSumTimes() {
        val fiveBucks: Expression = dollar(5)
        val tenFrancs: Expression = franc(10)
        val bank = Bank()
        bank.addRate("CHF", "USD", 2)
        val sum = Expression.Sum(fiveBucks, tenFrancs) * 2val result = bank.reduce(sum, "USD")
        assertEquals(dollar(20), result)
    }
}

フィクスチャーは今はスルー。書籍では Expressionインターフェイスなので Sumに実装を書いているが、私の実装では sealed class なので Money.timesExpressionに持ってきて、whenで分岐させれば十分だろう。

dataclass Money(val amount: Int, val currency: String) : Expression() {

    companionobject {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealedclass Expression {

    operatorfun times(multiplier: Int): Expression = when(this) {
        is Money -> Money(amount * multiplier, currency)
        is Sum -> Sum(augend * multiplier, addend * multiplier)
    }

    operatorfun plus(addend: Money): Expression = Sum(this, addend)

    dataclass Sum(val augend: Expression, val addend: Expression) : Expression()
}

テストも通ったのでこれでOK。

さて最後の「$5 + $5Moneyを返す」だが、結果的に何もしないことになるので、これで第16章が終わる。

  • [x] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 + $5 = $10
  • [x] $5 + $5Moneyを返す
  • [x] Bank.reduce(Money)
  • [x] Moneyを変換して換算を行う
  • [x] Reduce(Bank, String)
  • [x] Sum.plus
  • [x] Expression.times

まとめと振り返りなど

最終的な実装を以下に示す。差分を書き直すのが面倒なので GitHubのレポジトリと合わせるためにテストクラスに雑なフィクスチャー (っぽいもの) を作ったりMoney.currencyenum class Currencyとした実装を含めたりしている。

MoneyTest.kt

package money

import money.Money.Companion.dollar
import money.Money.Companion.franc
import money.Money.Currency.CHF
import money.Money.Currency.USD
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class MoneyTest {

    @Testfun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five * 2)
        assertEquals(dollar(15), five * 3)
    }

    @Testfun testEquality() {
        assertTrue(dollar(5) == dollar(5))
        assertFalse(dollar(5) == dollar(6))

        assertFalse(dollar(5) == franc(5))
    }

    @Testfun testCurrency() {
        assertEquals(USD, dollar(1).currency)
        assertEquals(CHF, franc(1).currency)
    }

    @Testfun testSimpleAddition() {
        val five = dollar(5)
        val sum = five + dollar(5)
        val reduced = Bank().reduce(sum, USD)
        assertEquals(dollar(10), reduced)
    }

    @Testfun testPlusReturnSum() {
        val five = dollar(5)
        val sum = (five + five) as Expression.Sum
        assertEquals(five, sum.augend)
        assertEquals(five, sum.addend)
    }

    @Testfun testReduceSum() {
        val sum = Expression.Sum(dollar(3), dollar(4))
        val result = Bank().reduce(sum, USD)
        assertEquals(dollar(7), result)
    }

    @Testfun testReduceMoney() {
        val result = Bank().reduce(dollar(1), USD)
        assertEquals(dollar(1), result)
    }

    @Testfun testReduceMoneyDifferentCurrency() {
        val bank = Bank()
        bank.addRate(CHF, USD, 2)
        val result = bank.reduce(franc(2), USD)
        assertEquals(dollar(1), result)
    }

    @Testfun testIdentityRate() {
        assertEquals(1, Bank().rate(USD, USD))
    }

    privateval fiveBucks: Expression = dollar(5)
    privateval tenFrancs: Expression = franc(10)

    @Testfun testMixedAddition() {
        val bank = setUpBankWithCHF2USDRate()
        val result = bank.reduce(fiveBucks + tenFrancs, USD)
        assertEquals(dollar(10), result)
    }

    @Testfun testSumPlusMoney() {
        val bank = setUpBankWithCHF2USDRate()
        val sum = Expression.Sum(fiveBucks, tenFrancs) + fiveBucks
        val result = bank.reduce(sum, USD)
        assertEquals(dollar(15), result)
    }

    @Testfun testSumTimes() {
        val bank = setUpBankWithCHF2USDRate()
        val sum = Expression.Sum(fiveBucks, tenFrancs) * 2val result = bank.reduce(sum, USD)
        assertEquals(dollar(20), result)
    }

    privatefun setUpBankWithCHF2USDRate(): Bank {
        val bank = Bank()
        bank.addRate(CHF, USD, 2)
        return bank
    }
}

Money.kt

package money

dataclass Money(val amount: Int, val currency: Currency) : Expression() {

    companionobject {
        fun dollar(amount: Int) = Money(amount, Currency.USD)
        fun franc(amount: Int) = Money(amount, Currency.CHF)
    }

    enumclass Currency {
        USD, CHF
    }
}

sealedclass Expression {

    operatorfun plus(addend: Expression): Expression = Sum(this, addend)

    operatorfun times(multiplier: Int): Expression = when (this) {
        is Money -> Money(amount * multiplier, currency)
        is Sum -> Sum(augend * multiplier, addend * multiplier)
    }

    dataclass Sum(val augend: Expression, val addend: Expression) : Expression()
}

Bank.kt

package money

import money.Money.Currency

class Bank {

    privateval rates: MutableMap<Pair<Currency, Currency>, Int> = HashMap()

    fun reduce(source: Expression, to: Currency) = when (source) {
        is Money -> Money(source.amount / rate(source.currency, to), to)
        is Expression.Sum -> Money(sum(source.augend, source.addend, to), to)
    }

    fun rate(from: Currency, to: Currency) =
            if (from == to) 1else rates[Pair(from, to)]
                    ?: throw IllegalArgumentException("Unregistered rate: $from to $to")

    privatefun sum(augend: Expression, addend: Expression, to: Currency): Int =
            reduce(augend, to).amount + reduce(addend, to).amount

    fun addRate(from: Currency, to: Currency, rate: Int) {
        rates.put(Pair(from, to), rate)
    }
}

今回取り組んだ3章に関しては、第13章で実施した設計の転換によって1つの機能に対して実装する箇所が基本的に1箇所に集約されるようになって、書籍と比べてあっちこっち見ずに済み感覚的に実装の負担が少ないように思えたのが印象的だった。とはいえ主な修正箇所が Bankだったこと、そもそもの実装内容が小規模だったことからそれ以外は特に気になるような差異はなかったように思う。まあ sealed class を使うことで分岐における網羅性の保証について無駄なことを考慮しなくて済むので、積極的に分岐を使って実装を集約しやすいという点は前回と今回で割と活きたのではないだろうか。

全体を通して Kotlin という言語に持った印象は、便利機能が標準で入っていたり Javaの Object にまつわるボイラープレート的な実装を省略できる機能があったりする点以外はやはり Javaベースの言語で、Kotlin は Java 8 とは異なる方向性の進化をした、順当な「モダンさ」を取り入れた Javaという感じだった。これは data class やsealed class を用いて色々な実装の手間が省けた以外では記述方法が異なる場所があるだけでおおよそ Javaの実装と変わらない形に落ち着いたなあという所感によるところが大きいと思う。ただ Bankの実装で見たように、Javaとは異なり最初からクラスやオブジェクトという構造を単位として作らなくてよく、関数という機能単位で実装を進められることは、ボトムアップなアプローチができるという点で設計のやりようが柔軟になっているかもしれない。この辺は実際に Kotlin でアプリなどを開発している現場が延べでどのくらい package-level function を作ってるのか聞いてみたい所である。

TDD に関しては、これまで聞きかじったり仕事でもなんとなくそれっぽくやってみていたりしたこともあってサイクルやテンポがとても自然に感じられた。ただ設計についての考え方や実装の粒度については学ぶところが多く、特に大きな流れの中で小さなサイクルを回しながら前進しつつ、ゴールにきちんと向かっていくという第5章から第11章までの流れがとても印象的だった。

そういえば途中で振り返りで触れるぞーと言っていた話題があったが、sealed class や enumを使ってバリエーションを開発側で制限するのって、例えばこの多国通貨のシステムがパッケージなりライブラリなりとして提供された時の使い勝手的にどうなのかなーというもので、ユーザ定義で増やせる方が使いやすいとかでも予期しないエラーがどうのこうのとかそういう感じの話だったのだが、TDDとはそこまで関係ないなーと今になって思ったのでこれだけ。最後に currencyenum classにしたのはやっぱそっちの方が楽だよねという気持ちがあって、でも最後にやっても何の恩恵にもあずかれないのであった。

Part II 以降については別にやりたいことを進めているため、読み進めているだけで写経は一旦お休みという状態である。こっちも写経の様子をブログにできたらとは思っている。

Kotlin で『テスト駆動開発』を進める

$
0
0

記事のまとめ

Part I

1~4章

5~7章

8~11章

12~13章

14~16章

Part II

まだ

年明けから今日まで

$
0
0

継続的に文章を書くという試みとはなんだったのかという様子ですね。とりあえず年が明けてからのことを書きます。

1月上旬〜中旬

年末年始休暇が終わっていきなり気分が沈み始め、業務の進捗も出なかったことが追い打ちになって完全に精神が終了していました。この時期Twitterではずっとダメになったとか終わってしまったとか呟いてたと思います。現実ではよくわからなかったのでリムスキーコルサコフ管弦楽法のテキストを買って読んでました。
ところでこの時期割と頻繁にチームの同僚に心情の吐露をしていたような気がするのですが、振り返ってみるとうちのケースにおいてはあまり良くなかったなあという反省があり、具体的には人の面倒を見ることに明るくない人間に感情に関する曖昧な共有をしてもただ困らせことになるだけだということです。この辺は同僚に対する愚痴ではなく弊チームの成熟度合いや体制プロセスetcと様々な要素が絡んでおり何かが悪いというより不運な事故が発生したという認識で、むしろ相手からしたら唐突に「私はしらみだ...」とだけ言ってくる同僚なんてどうしろってんだという感じだったでしょう。

1月下旬〜今日

下旬くらいになってからだんだん精神が自然回復してきて、業務の進捗も出るようになりました。そういえば弊社でもやっとAndroidアプリ開発にKotlinが導入されるようになってやっと文明の火を得たという心持ちです。
このくらいに昼食を外食からCOMPとプロテインミックスしたものに変えてみたり、『シリコンバレー式 自分を変える最強の食事』を読んで朝食をココナッツオイルコーヒー+αにするなどしてみたところ日中に頭がはっきりした状態を保てるようになって、それにつられて気分もだいぶ良くなりだいたい本調子に戻りました。最近はその辺が面白くて毎日食事の調整をしながら体調を観察して継続的に良い調子を保てる食事メニューを考えています。
最近はチームにペアプロ・モブプロを導入しようと色々やっています。弊チームは性格的にプルリクとコードレビューがだらしなくなりやすい (1つのプルリクが巨大になる、レビューが五月雨でマージされるまで時間がかかるなど) のでその辺の解決になるといいなーとか考えながら進めています。

とりあえず書きたいことをとにかく書き出そうという書き方をしたので脈絡のない乱文になりましたが何かしら文字に起こせて満足したのでいいやという所感です。溜めに溜めてビッグバンリリースをするから酷い事になるという様を表しているということにします。

ところで諸々のメモ書き的なものは使い勝手からScrapboxの方でやろうというということにしていて、こっちでは何かしら文章の体裁をとったものを投稿する時に使うことにしました。とはいえこっちもまだろくなこと書いていません。

最近もやもや考えてる事

$
0
0

DroidKaigi 2018 が終わり続く三連休も過ぎ去って体調が低空飛行なりに業務に復帰しつつ考えてる事:

スプリント期間の短縮をしたい

今のチームというか会社では全体的に1スプリント2週間で回しているのだけど、土日を挟む事で勢いがリセットされてしまって2週目はいつもダレているような気がしている。(ダレてるのは) 自分だけかもしれないけど。
今週はその2週目にあたる週で、先週と比べて明らかにタスクの流れが滞っている。自分が持っていったタスクが一旦片付いたので昨日今日は勤務時間の半分くらいずつを使って溜まってしまっていたPRを全部レビューしてLGTMしたりコメントつけたりしたのだが、だいたい半分くらいしかその後のアクションが行われず、まあこれはうちのチームのPRとそのレビューがだらしない傾向にあるのも一因なのだけどなんだかなーという気持ちになったのでこのような事を考え出した。
それでスプリントを2週間ではなく1週間にすれば毎週新しいスプリントなので毎週リフレッシュされるしいいんじゃないかなーとか、計画とレビューが毎週発生するようになってMTGの時間がーという風にも考えられるがスプリントが短い分その中でこなすべきタスク量も2週間分と比較して少なくなって、MTG1回あたりの時間も短くなるんじゃないかとか、1週間でできるタスクの大きさに制限する事でより見積もりがしやすくなるんじゃないかとか考えていた。
とまあこの程度でまだ社内にうまく提案するまでまとまってないのでWIP。

PRとレビューがだらしない

上でもちょっと出たけどだらしないというのはどういうことかというと1つのPRが大きくなりがちだったりレビューもそれに対するリアクションも遅めで、典型的なコードレビューが開発のボトルネックになっている環境。
でかいPRというのは本当に厳しくて、まあレビュイーについてもコードを見て気持ちになってしまったというのはわかるのだけど見きれないよ... となり、レビューもだんだん雑になってきて結果的に無をしてマージ、みたいなことになる。
これは今ペアプロ・モブプロを導入しようとしている最中で、それで解決に持っていきたい。

会話の中で「話すべきこと」と「話したいこと」の分別がつけられずに発言すること

これは自分にも身に覚えがあるので自戒でもあるのだけど、相談事をしている最中に自分語りみたいなのを始めて相談や質問の回答が返ってくるまで数分以上かかったり、もしくはこちらから促さないと返ってこなかったり、真面目に議論している最中に突然脱線させて帰ってこなくなったりみたいなことが発生すると本当につらい。
これはやってしまっている本人は気づいてないというか、文脈を読み違えてしまった故の悲劇というか、意識しないと誰でもやってしまうように思われる。多分発生したら直接指摘するしかないのだと思うが、本人は自然に話をしているつもりなのだろうから忍びなく思ってしまう。故意だったら質が悪い。

Ergo42 を買った

$
0
0

動機と購入までの経緯

一昨年前くらいに購入した ErgoDox EZチャタリングを起こすようになったのと、 ErgoDox の形状に100%満足していたわけではないので別のも試してみるかーと思って探してみたけど近頃の流行りは自作で電子工作力も道具もない私にはちょっと手を出しにくいなーってなってたところ、組み立て済みの物を販売していてちょうど在庫があったので買ってみた。もし工作力と設備があれば dactyl manuform 作りたいのだけど...

tanoshii-life.booth.pm

Keymap

現状こんな感じ

最初は defaultベースに ErgoDox の時のを組み合わせた感じにしていたのだけど、キー数が少ない分レイヤ移動が頻繁に起こることが考慮されてない構成だったのでひたすら数字や記号が打ちにくく終わっていたのでとりあえず META レイヤの切り替えを親指の位置に持ってきたり他のキーもいくつか親指周りで重ねてみたりということをして様子を見ているという状況。

自分がまだ Ergo42 のキーの少なさに対応できていない感じがしていて、キー配置の考慮が行き届いていないと思うところがあり、もうしばらく模索しないとなーとなっている。今わかっていることはとにかくレイヤ切り替えが簡単にできること、 MOLTだったり modifier key だったりホールドする必要があって指の可動域が制限される状況を想定した時にどの指でホールドして何ができるようにしておくべきかを考慮した配置にすること、あたりが肝ではないかという感じ。ホールド用の modifier を組み合わせたキーとか作っとくといいかも?

今のところの感想

コンパクトさ

デスクの上を占める面積だったり持ち運びやすさだったり ErgoDox と比較してこのアドバンテージはかなり大きい。また殆どホームポジションから手を動かさずにタイピングできるのも楽で、ErgoDox では外側の端や親指のいくつかのキーを押すのに指を思い切り伸ばすとか割と無理なことしていたけどそういうこともなくなった。あとは個人的な状況でいうと Roost の RKM carrying case に収まるのでそれだけでキーボードとスタンドとケーブル類、その他小道具をまとめて持ち運べて大変便利している (ErgoDox は片方すらケースに収まらない)。

www.amazon.com

キー数・配置

4x7 は割とちょうど良いかもなーと keymap 考えている時に思った。必要なキーがきっちり収まって、1, 2キー余るという印象。5行あって数字キーも BASE と同じレイヤで使える方が馴染みやすいと思うけど4行でも案外いけるものだという感じ。
ただ中指・薬指・小指の列の一番下のキーはシームレスにタイプするのは無理だなーと思ってて、この位置のキーが親指のあたりにあったらもっとやりやすいかなーという気持ちになってる。Crkbd の親指まわりのキー数が5~6個あるみたいなのがちょうど良いかもなーって考えている。

booth.pm

おわりに

まだ使い始めて2週間とかそこらで慣れきってないし keymap も定まってない状況なのでキーボードに思考が奪われることがしばしばあって生産性死んでるけど良い感じだと思う。

ここまで書き終わってからこれを読んだのだけど意図せず製作者の考えてたことをたどりながら使ってた感がありなんか (私が) よかったですねってなった。

あ、読んだ流れで pixivFANBOXの支援はじめました。

余談

Ergo42 の購入を決める前に ErgoDox EZ 買い直すかーとも考えててサイト見に行ったらなんかはんだいらずでキースイッチを付け替えられるようになってたので最高では??????????? となりとはいえ2つも買うのはどうだろうと悩んだ末にその時は Ergo42 だけ買うことにしたんだけど結局 ErgoDox EZ も買ってしまいましたとさ。


Kotlin Android Extensions の view binding に関する知識の整理

$
0
0

Kotlin Android Extensions の解説記事、Kotlin のリファレンスとか英語ソースだったらよくまとまってるのがあるけど日本語ソースだとあんまりいい感じのないなーという印象なので自分でまとめてみる。ただし view binding の方だけ。英語が読めるなら以下の記事を読めばほぼ十分という感じの内容。

kotlinlang.org

antonioleiva.com

Kotlin Android Extensions の view binding ってなに

  • View の要素に property accessをするようにアクセスできるようにする
  • アクセスした view をキャッシュしてくれて2度目以降は findViewById()が呼ばれないようになる (ならない場合もある)

導入

build.gradle

apply plugin: 'kotlin-android-extensions'

使い方

1. Activity, Fragment, カスタム View

欲しい要素のID名で property access風にアクセスする

// e.g. Activity の場合: activity_main に id が greeting_text な TextView があるとするclass MainActivity : Activity() {

    overridefun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        greeting_text.text = "Hello, World!"
    }

    // ...
}

Fragment における注意点

Fragmentにおいては onCreateView()でアクセスすると NullPointerExceptionになるため、Kotlin Android Extensions でのアクセスは onViewCreated()以降で行う必要がある。これは Fragmentでは内部的に Fragment#getView()を用いて root view にアクセスしており、その返却値は onCreateView()が返す値であり、onCreateView()が呼ばれている時点では nullが返ってくるから。

2. 任意の View インスタンスに対する子 View へのアクセス

例えば ViewHolderの中で itemView.titleといった感じで property access風に子 View にアクセスできる。

挙動について

2種類の package

実際の挙動を見てみる前に、 Kotlin Android Extensions の view binding には2種類の package があることを紹介する。

  • kotlinx.android.synthetic.<main or flavor>.<layout_name>.*
    • Activity, Fragment 内で自身の要素にアクセスする場合はこちらが import される
    • また後述する LayoutContainerの実装クラスでもこちらが使われる
    • 以後 .*と呼ぶ
  • kotlinx.android.synthetic.<main or flavor>.<layout_name>.view.*
    • カスタム View 内で自身の要素にアクセスするときと、任意の View インスタンスの子 View にアクセスする場合はこちらが import される
    • 以後 .view.*と呼ぶ

アクセスした View のキャッシュ

Activity と Fragment では.*が使われアクセスした要素をキャッシュするようになる。これはバイトコードデコンパイルして生成された Javaコードから確認でき、view binding で View へのアクセスを実装した Activity/Fragment には _$_findCachedViewById()という関数が生えて、.*でアクセスしている部分はそれを使って View へのアクセスするといったコードになっている。

// Activity の場合private HashMap _$_findViewCache;

   public View _$_findCachedViewById(int var1) {
      if (this._$_findViewCache == null) {
         this._$_findViewCache = new HashMap();
      }

      View var2 = (View)this._$_findViewCache.get(var1);
      if (var2 == null) {
         var2 = this.findViewById(var1);
         this._$_findViewCache.put(var1, var2);
      }

      return var2;
   }

2.の用法の場合、.view.*が使われるが、Javaデコンパイルした結果を見ると単に対象の View から findViewById()でアクセスしているだけだったりする。なのでこちらの使い方をするとアクセスした View がキャッシュされず、アクセスする度に毎回 findViewById()が呼ばれるので注意。

ところで package の紹介でも触れたが 1. の用法でカスタム View の場合 View のアクセスをキャッシュするようになりデコンパイルすると _$_findCachedViewById()が生えているのが確認できるが、 import されるのは .view.*の方である。Package とキャッシュの有無に関連性があるわけではない。

あと、例えば自分で実装した Activity の拡張関数内で view binding で View にアクセスするように実装した時はキャッシュが効くが、ライブラリの ActivityAppCompatActivityなどに対する拡張関数で同様の実装をした場合は、たとえレシーバが自分の実装した Activity だったとしてもキャッシュが効かない。大雑把に解釈するとどこから呼ばれるかわからないからという感じだけど、そもそも特定のレイアウトと紐ついている view binding をそんな汎用的な所で使わないよね、複数の Activity でレイアウトを共有するようなことがあってもそのレイヤで共通化するのはちょっと... という所感。

Fragment における注意点再訪

「使い方」で onCreateView()中で呼ぶと NPE になると書いたが、例えば以下のように書くと NPE にならなかったりする。

overridefun onCreateView(inflater: LayoutInflater, 
                          container: ViewGroup?, 
                          savedInstanceState: Bundle?) : View? =
    inflater.inflate(R.layout.fragment_foo, container, false).apply {
        greeting.text = "Hello"
    }

どういうことかと言えば、inflate した View に対する applyの中で実行しているため、Fragment に対してではなくここで生成した View の子 View に対するアクセスと認識され、 .view.*の方が使われているという感じ。この場合ここでのアクセスは先ほどの説明の通りキャッシュされないので意図的でないならやめておいた方が良い。Fragment 内で初めて書いた View へのアクセスがこれだった場合に起こることが多く、他ですでにアクセス処理を書いた上でこのように書くと .*の方を使われて NPE になるなんてこともある。

LayoutContainer

View を property として持つ任意のクラスで View のアクセスをキャッシュしてくれるようにする。ViewHolder でよく使う。

1.3.0 RC 現在 experimental なので使う場合は experimentalフラグを trueにする必要がある。

build.gradle

androidExtensions {
    experimental = true
}

使い方

対象のクラスに LayoutContainerを実装する。

LayoutContainerは↓のような感じ。View binding の対象となる root view を property として持つだけのシンプルなインターフェイス

publicinterface LayoutContainer {

    publicval containerView: View?
}

実装する側は例えば RecyclerView.ViewHolderなら以下のような感じ:

class ItemViewHolder (
    overrideval containerView: View) 
        : RecyclerView.ViewHolder(containerView), LayoutContainer {

    fun bind(item: Item) {
        item_title.text = item.title
        item_description.text = item.description 
    }
} 

こんな感じで実装してると synthetic.main.viewholder_item.*みたいに .view.*じゃない方が import されてるし、デコンパイルして見てみるときちんと _$_findCachedViewByIdが生えててそれが使われているのがわかる。

// デコンパイルの結果publicfinalclass ItemViewHolder extends ViewHolder implements LayoutContainer {
   @NotNullprivatefinal View containerView;
   private HashMap _$_findViewCache;

   publicfinalvoid bind(@NotNull Item item) {
      Intrinsics.checkParameterIsNotNull(item, "item");
      TextView var10000 = (TextView)this._$_findCachedViewById(id.item_title);
      Intrinsics.checkExpressionValueIsNotNull(var10000, "item_title");
      var10000.setText((CharSequence)item.getTitle());
      var10000 = (TextView)this._$_findCachedViewById(id.item_description);
      Intrinsics.checkExpressionValueIsNotNull(var10000, "item_description");
      var10000.setText((CharSequence)item.getDescription());
   }

   @NotNullpublic View getContainerView() {
      returnthis.containerView;
   }

   private ItemViewHolder(View containerView) {
      super(containerView);
      this.containerView = containerView;
   }

   public View _$_findCachedViewById(int var1) {
      if (this._$_findViewCache == null) {
         this._$_findViewCache = new HashMap();
      }

      View var2 = (View)this._$_findViewCache.get(var1);
      if (var2 == null) {
         View var10000 = this.getContainerView();
         if (var10000 == null) {
            returnnull;
         }

         var2 = var10000.findViewById(var1);
         this._$_findViewCache.put(var1, var2);
      }

      return var2;
   }

   publicvoid _$_clearFindViewByIdCache() {
      if (this._$_findViewCache != null) {
         this._$_findViewCache.clear();
      }

   }
}

キャッシュ方法の変更

_$_findCachedViewById()によるキャッシュはデフォルトでは HashMapを使っているが、これを変更することができる。これも 1.3.0 RC 現在 experimental となっている。

選択肢は HashMap / SparseArray / キャッシュ無し。build.gradle に書くことで project 全体に、@ContainerOptionsアノテーションを使うことでクラス単位で設定できる。

build.gradle に書く場合

androidExtensions {
    defaultCacheImplementation = "SPARSE_ARRAY"
}

アノテーションを使う場合

@ContainerOptions(cache = CacheImplementation.NO_CACHE)
class FooActivity : Activity()

切り替えたことないので特にどれがどうという知見はない。

おわりに

Kotlin Android Extensions の view binding に関して書けるだけ書いてみた。表面的には View へのアクセスが簡単にできるもの程度だけど、その裏側でどのように View を取得しているかとか、キャッシュの有無とかまで知っておけば変な挙動に悩まされるみたいなことはないと思われる。あとはバイトコードから Javaデコンパイルしたものを読めばどういう挙動になっているか大体把握できるのであれ? と思ったらとりあえずデコンパイルするとよい。

Kotlin Android Extensions にはもう一つ Parcelable関連の機能があるけど、これは今回の関心事から外れるし記事にするほど情報量ないので公式の解説読んでください。

参考

Redux に関する昨日の出来事

$
0
0

前提: 当時の私の Redux に対する認識や知識

  • Flux: MVC, MVP, MVVM などと同じレイヤーで扱われるアーキテクチャパターン
  • Redux: Flux を実現するための具体的な実装、ライブラリ
  • Flux については 2014年末くらいに割としっかり調べてて情報を追っていたりした
  • Redux はそういうライブラリがあって今メジャーな存在になってるな程度の知識
    • あとは Elm から影響を強く受けているとかそういう話くらい

疑問

モバイル (Android, iOS) 界隈で "redux"について言及する時に特定の実装を指していないような気がするんだけどこの場合の "redux"って何だ?

投げた一連のツイート

初めて知ったこと

誤解? してたこと

  • Androidにも iOSにも reduxjs/redux に相当するような "redux"という実装 (= ライブラリ) は存在しない
    • iOSには ReSwift というライブラリが存在して、それが reduxjs/redux に相当する
      • ReSwift の redux 部分の前身として ReduxKitというのがあった
    • Androidには無いっぽい?
  • "redux"という語は特定の実装を指す
    • Three principles に則っていればそれは redux と呼べるみたいな認識っぽい
    • つまり一種のパターンとして認識されていることになる

Android界隈に関しては reduxjs/redux のコンセプトだけ輸入して各々実装をしているという状況のように見えるので私の混乱の原因はそれっぽい。上に書いたように iOSは ReSwift (ReduxKit) が redux の実装として使われているようだけど、実装と共に redux が伝えられたのかは調べてないので不明なのと、あと名前が変わって "redux"という語がなくなってしまったので結局 "redux"はコンセプトだけを指すようになっていると認識している。

結局 reduxjs/redux がJS界隈でどのように普及・認識されていって、それがどのような形でモバイル界隈に輸入されていったのか知らないまま適当な発言をしていたという感じで、今もよくわかっていない。

DroidKaigi 2019 感想

$
0
0

DroidKaigi 2019 に参加してきた。

droidkaigi.jp

セッションのノートとかは Scrapboxにまとめてある。これを書いている時点でまとめきれていないものがあって #WIPってついてるのがそれ。 scrapbox.io

感想

聴いたセッションで特に印象的だったもの

Deep Dive to fido.fido2 Packages

最近とんとセキュリティ関係のトピックに触れてないなーって思ってて、なんか最近FIDOっていうのが注目されてるらしいので何も知らないけどとりあえず聴いてみて雰囲気だけでも掴んでみよう!というモチベーションで聴きに行った。実際セッションを聴いてFIDOについてどれだけ理解を得たかというとまだふわっとしかできてないと思うのだけど、認証まわりのどんな課題に対してFIDOがどんなアプローチをしているのかとか、attestation と assertion の大まかな流れとか、 fido.fido2周辺のAPIにどんなのがあってどういう風に使って認証のUIを実現するのかとか、これから学習していくための良い introduction だなーと思う。
AndroidでFIDOの認証フローを実装するのは、送受信するデータのプロパティが多くてゴツい印象があったけどUIに関してはAPIから Intent もらって launch するだけでUIそのものを実装しなくて良く、そんなに手間ではないように感じたので開発してるサービスでFIDOの認証採用するよ!ってなった時にシュッと実装できるようになっておくとよさそう。
それと最後にアプリの実装だけでなくFIDOを採用するにあたってカスタマーサポートやサーバサイドなど各方面と、特にユーザの認証のリカバリまわりでうまくオペレーションできるように連携しましょうねって話があってこういうの大事だよねと思った。この辺の話は同じ日の最後のセッション "Chrome + WebAuthn で実現できるパスワードレスなユーザー認証体験と開発者の課題"でより進んだ言及があったので、サービスでの運用まで情報を拾っておきたい場合は合わせて見ておきたい。

ところで新しい概念について覚えたり理解しようとするのにいっぱいいっぱいでノートがろくに書けなかったのでまた録画見て理解したことを整理してノートにまとめないとなーってなってる。

R8、Proguard徹底比較

聴く前はパフォーマンスとか shrink の比較とかかなーって雑に考えてたら Dalvik バイトコードを読み始めたり R8 の最適化の一つである lambda group がどのように行われるかを R8 の実装を追いかけながら解説したりとなかなかアツい展開になってすごく満足度が高かった。
SDKとかもそうだけど私はあんまりそういうののソースコード読まないよなーって気づいて、document だけじゃなくてコードも読んだ方がいいのかなー今年はその点を頑張ろうかなーともやもや思ったのであった。

あと、発表者の方も自分で言っていたけど発表のボリュームに対して時間が足りなくて余裕のない感じだったので50分枠だったらなあと思わずにはいられない...

multi-module Androidアプリケーション

マルチモジュールっていうとビルド時間を短縮するみたいな文脈で語られがちな印象があったけど、依存関係の強制で設計のほころびを起こしにくくするというのはなるほどってなった。確かに Javaから Kotlin になって失った package private の代わり(?) の internal を活用する設計とか考えるとそうなるよねーというのはあって、そういう文脈でもマルチモジュール化の流れはあるのかーという納得があった。
あとはビルド時間の短縮のためには各モジュールのサイズとか依存関係の形にも注意が必要だとか、まだ本格的にマルチモジュール化を行ったことがない身としては知見にあふれるセッションだったし、スライドもよくまとまっているうえになんというか発表が落ち着いててわかりやすいというか聴きやすいトークだったなあという印象がある。

Lifecycle, LiveData, ViewModels - The inner wiring

droidkaigi.jp

これ聴くつもりだったのだけど当日疲れてていいやってなってパスしたところTLが盛り上がっててこれ絶対楽しかったやつだ... ってなってた。録画がもう公開されてるしあとで見る。

全体の感想

去年の DroidKaigi 2018 が DroidKaigi 初参加だったので去年との比較になるのだけど、なんだかセッションのバラエティが偏ってるように感じてどのセッションを聴くのか決めるのに少し悩んでしまったのがある。ただこれは単にネガティブな感想だけではなくて、同じトピックであっちは同じ時間帯に聴きたいセッションあるからこっちにしようみたいなことができて助かったみたいなのもあり、セッション選定とか時間割組み立てとかやっぱり難しいんだろうなと思った。
他には設計・テストみたいな大きなトピックとクロスプラットフォーム関係が多いなーという印象があった。前者は Androidアプリ開発界隈が成熟してきてるのかなーって感覚で、後者はやっぱり注目度が上がっているのだなという感じ。
クロスプラットフォーム系の何か1つは習得しておきたさがあるのだけど、今のところ web も AndroidiOSもそれぞれ人数揃ってて精通しているメンバーがいるようなところにいるのであんまり採用するモチベーションがないよなってなってて優先度が上がらない...
あと今回のPWAのセッションで話があったようにPWAの web アプリをモバイルアプリとして PlayStore で公開できるようになったなどあるので iOSも同じような流れがあるならPWAの方が需要でそうな気がしてちょっと立場が厳しくなるのではないかみたいなのをちょっと気にしている。

展示ブースは色々見て回れて、今年もバリスタコーヒー頂けたし Kotlin Quiz では7問中6問正答で Kotlin ももっときちんと勉強しないとなーってなったし Twitterでよく見るような方とお話できたしとても楽しかった。

運営・登壇者の皆さまお疲れ様でした。とても楽しく刺激的な2日間でした。来年もまた参加できたらなと思います。おわり。

******

$
0
0

From: U-NEXT (2016/05~2019/02)
To: CyberAgent (2019/03~)

2/15が最終出社日でした。

U-NEXTのみなさまには大変お世話になりました。火曜に退職決まって金曜に最終出社という唐突ムーヴ失礼しました。タイミングが良かったんです。結局挨拶もそこそこみたいな感じになってしまったので話し足りなかったとかあったらご飯とかお酒とか誘ってください。

ところで昨日以下のようなツイートをしました。

これはどういうことかというと、既に内定承諾はしているのですが、入社までの期間が短いため配属がまだ調整中だけど入社手続きをもう進めちゃうねみたいな状況で、具体的な配属がまだ決まってないので決まってからブログでも書こうということでした。でも待ってたら来週末くらいとかになるかも知れないし、イベント的にそこまで引っ張る必要もないよなーって思ったのでもう書いてしまっています。

というわけで、サイバーエージェントのみなさまこれからよろしくお願いします。人事のみなさまにおかれましては月の半ばにもかかわらず「今月で退職できるみたいだから来月入社しますね!」という唐突ムーヴすみません。本当にタイミングが良かったんです。あと多分最初に連絡をもらった時の「明日🍣どうですか?」の件をいまだに面白がってるのだと思います。

なお、この記事は以下のレギュレーションに則って書かれました。

Android で unidirectional data flow で設計することについてのメモ

$
0
0

Android と Flux とか unidirectional data flow とよばれるものの関係についてかんがえていることのメモ書きのために自分の過去の tweetをまとめる場所

Bitrise: Repository で bitrise.yml を管理している場合の Bitrise Start Build による workflow の並列実行を行う

$
0
0

Bitrise は基本的に1つのトリガーに対して1つの workflow しか実行させられないが、 Bitrise Start Build という step を用いることで、複数の workflow を並列に実行できるようになっている。以下では主に GitHubなどの repository で bitrise.ymlを管理・運用しながら Bitrise を利用している場合の Bitrise Start Build の導入方法や、実際に私が導入した際の雑感などを書いていく。

事前準備

Bitrise Start Build を実行するには access token が必要で、これは各個人のアカウントで生成する personal access token のことである。なので、適当なアカウントで Account setting ->Securityから token を生成し、Workflow Editor の Secrets適当な名前で登録しておく。

(寄り道) GUIでのセットアップ

今回の主題は repository で管理している bitrise.ymlで運用している場合の導入方法についてだが、yamlを書く際のドキュメントは存在しない*1ので、その場合でも先にGUI側でお試し workflow を作ってどのような設定があるか見てみるのが良い。Bitrise Start Build についてもGUI側での設定方法ならば上記の access token についても含めて公式のドキュメントにおおむね書かれている。大雑把に要点を挙げると

  • Bitrise Start Build step を追加
  • 並列実行したい workflow を羅列
  • 用意した access token を登録

という感じ。

また、ドキュメントには記述がないが、実行元の workflow で定義した環境変数を引き継ぐ為の設定 Environments to shareというものがあり、これによって1つの workflow に対して実行元 (設定した環境変数の値) に応じて振る舞いを変更する、といったことにも対応可能になっている。

ついでに、 Bitrise Start Build の step の後に他の step を追加して、並列実行した workflow の実行後にそれを動かすようにしたい場合以下の2種類の方法がある。

  • Bitrise Start Build の設定にある Wait for buildstrueにする
  • Bitrise Wait for Build という step を Bitrise Start Build の後に追加する

これらの振る舞いは完全に一緒っぽいのでそのうちどちらかがなくなるのではないか。

Repository でbitrise.ymlを管理している場合のセットアップ

bitrise.ymlでの記述

yamlにおける Bitrise Start Build の step 名は build-router-startであり、設定など含めると以下のようになる:

steps: # ssh activation, cloning repository, etc...- build-router-start:inputs:- access_token:"$ACCESS_TOKEN" # 事前準備で用意した access token- workflows: |- # 実行する workflow のリスト
          foo
          bar
          baz
      - wait_for_build:false # optional: default false- environment_key_list:"$KEY1\n$KEY2" # optional

これでめでたく workflow の並列化が完了... ではない。単に上記の step を追加しただけで実行すると以下のようなエラーで終了してしまう:

Failed to start build, error: failed to get response, statuscode: 400, body: {"status":"error","message":"workflow (foo) did not match any workflows defined in app config","slug":"[REDACTED]","service":"bitrise"}

これはなぜかというと、現在の Bitrise Start Build で実行可能な workflow はGUI側で定義されているもののみだからである。
ここで諦めて大人しくGUI側に workflow の定義を移すこともできるが、折角変更の管理ができるように repository 側で持たせているのだから、その状態を維持したままで並列実行を動かせるようにしたい。とはいえ repository 側の bitrise.ymlだけではどうにもできないので、GUI側への最小限の変更で動かせるようにする方法を紹介する。

GUI側での設定

今の所参考になる解決策は以下の discussion で挙げられている手法かと思われる。

discuss.bitrise.io

要点は以下の3つ:

  • GUI側で、Bitrise Start Build で実行する対象と同名の workflow を定義する
  • 環境変数を用いて、実行するべき workflow を保存しておく
  • bitrise run環境変数で保存していた名前の workflow を実行する

ちなみに上のリンク先の定義は改善の余地があって、自前で WORKFLOW_TO_RUNのような環境変数を用意しなくても、$BITRISE_TRIGGERED_WORKFLOW_IDというデフォルト環境変数があり、同名の workflow を定義しているならこれが使えるので、こちらを使うことで定義をスッキリさせることができる。

run_from_repo:steps:- activate-ssh-key:{}- git-clone:{}- bitrise-run:title: continue from repo
        inputs:- workflow_id:"$BITRISE_TRIGGERED_WORKFLOW_ID"foo:after_run:- run_from_repo
  bar:after_run:- run_from_repo
  baz:after_run:- run_from_repo

この方法を用いる場合の注意点として、bitrise-runで実行した workflow 内の script step で set -eをつけておかないとタスクが失敗したにも関わらずその後の処理も続いて workflow が成功扱いになってしまうみたいなことがあったので共有しておく。

雑感

  • 現時点では Bitrise Start Build はGUI側のみでの workflow 構築しか想定されておらず、repository で bitrise.ymlを管理している場合には定義が repository 側とGUI側に散ってしまい嬉しくない。Bitrise 的にはGUI側で全てを完結してほしそうにしているが、 workflow の変更管理がそれだけではできないのでGUIで完結させたいなら早く運用でカバーする以外の管理方法と提供してくれという気持ち。
  • wait: trueにしている場合、実行したコンテナの結果を待っている間そのコンテナは占有されたままとなるので、CircleCI と比べるとコンテナが枯渇しやすい。
  • Bitrise では現在 branch 単位でしかキャッシュを持てず、他にコンテナやCIパイプライン間でファイルを共有するすべがないので、たとえ wait: trueにしたとしてもそれぞれの workflow の成果物を収集してまとめて何かをするというのはできなさそう。
    • 基本的には build_router_startを実行する workflow はそれのみを step として持ち、自己完結した workflow を並列実行して自身は wait しない、という構成にするのが一番無駄が無い気がする。
    • Danger に色々な仕事をさせていて、成果物の収集は互いに独立しているので並列化したかったのだけど残念...

まあ CircleCI の workflow とかと比べても洗練されてないというか色々足りてないなーという印象だけど、まだリリースされてそれほど時間が経っていないことだし、今後に期待という感じだろうか。

References

*1:Bitrise は基本的にGUI側での設定の仕方しかドキュメントにまとめてくれない

Bitrise Test Reports について調べてみた記録

$
0
0

先週 Bitrise の build log に "Test Reports"って項目が増えてて、JUnitとかのテスト結果をアップロードしたらGUIでいい感じにみられるようにしてくれる機能が追加されたことを知った。公式のドキュメントを読んだ感じだと Bitrise から提供されてるいくつかの test step の後に Deploy to Bitrise.io 使ったらアップロードされるよと書いてある他に、対応しているファイル形式についても書いてあった。それによると plistJUnit XMLに対応しているとのことで、じゃあアップロードの仕組みがわかれば指定されてる test step を使わなくてもできるのでは、と思って色々試したり step のコードを読んだ結果として面倒くさくなって諦めたことについて書き残しておく。

ドキュメントを読んでやってみたこと

私が今 Androidアプリ開発をやっているということで読んだのは以下2つ:

特に2つ目の以下の記述について注目した:

You can check your Android unit test results on the Test Reports page. The Android Unit Test Step generates and exports unit test reports into the $BITRISE_TEST_DEPLOY_DIR folder. Then the Deploy to Bitrise.io Step exports those reports from the $BITRISE_TEST_DEPLOY_DIR folder to the respective build’s Test Reports page where you can view the test results.

ここの記述から $BITRISE_TEST_DEPLOY_DIRにテスト結果 (<module>/build/test-results) を放り込んだらアップロードできるのではと考えて適当にテスト結果をコピーしてみたところ、何もアップロードされず Test Reports には何も記録されなかった。

コードを読んでわかったこと

どうもファイルがあれば良いという単純な話ではなさそうなので、Test Reports へのアップロードを担当している Deploy to Bitrise.io step と、テスト結果の収集方法についての参考に Android Unit Test step のコードを追ってみた。

github.com

github.com

Deploy to Bitrise.io の方は、まず main.goの以下の部分が対応している:

https://github.com/bitrise-steplib/steps-deploy-to-bitrise-io/blob/f1ce02dacdb35d56c05b3e6fc1522ad53828ca33/main.go#L229-L246

config.TestDeployDir$BITRISE_TEST_DEPLOY_DIRを指しているのでそこに対象のテスト結果を配置することは間違っていないようだ。なので、test.ParseTestResultsが何をしているか紐解く必要がある。これは repository の test/test.goに記述されている。

https://github.com/bitrise-steplib/steps-deploy-to-bitrise-io/blob/f1ce02dacdb35d56c05b3e6fc1522ad53828ca33/test/test.go#L120-L212

ParseTestResultsは長いのでこちらに載せないが、要点をまとめると、

  1. testRootDir ($BITRISE_TEST_DEPLOY_DIR) 配下の各ディレクトリ (以後 testDir) に対して走査を行う
  2. testDirから step-info.jsonを取得する、当該ファイルがない、もしくは指定された schemeに合致しない場合は対象ディレクトリに対する処理をスキップ
  3. testDir配下の各ディレクトリに対してファイル取得を行う
  4. 取得した各ファイルに対して、対応フォーマットだった場合には内容を読み取って返却する resultsResult型のデータ構造として追加する、その際 testDir配下の各ディレクトリに test-info.jsonが存在することを期待している

という感じになる。ここから、単にテスト結果のディレクトリを $BITRISE_TEST_DEPLOY_DIRに配置しただけでは、step-info.jsonがないのでアップロード対象にならないので Test Reports で表示できないということがわかる。また、仮に step-info.jsonが存在したとしても、さらに各 test phase (Androidというか gradle でいうところの各 task, testDebugUnitTestとか testReleaseUnitTestとか) に対して test-info.jsonが無いといけない。

test-info.jsonについては関数内で test-nameという string 型の property があれば良いことがわかるが、 step-info.jsonの必要な schemeについては model.TestResultStepInfoを見る必要がある。これは別の repository にあって、該当するのは以下のリンク先にある。

https://github.com/bitrise-io/bitrise/blob/master/models/models.go#L128-L134

とりあえず必要な schemeは↓のとおり

{
  id: string,
  version: string,
  title: string,
  number: number
}

ということでアップロード側が対象を認識する部分についてはなんとなくわかったので、今度は対象を作って配置する側で、主にテスト結果ファイルや step-info.jsontest-info.jsonの中身がどうなっていればよいのか? を見るために Android Unit Test の repository をのぞいてみる。

とりあえず main.goから、関係するのはこの辺: https://github.com/bitrise-steplib/bitrise-step-android-unit-test/blob/dd2535d711f8515202beb3f02ef43c9b2c268b5b/main.go#L215-L233

artifact によって unique なディレクトリを作って、そこに xmlを export してるっぽいことが読み取れる。ディレクトリは <module>-<variant>って名前。Export 処理については testaddon/testaddon.goの方で、test-info.json作って xml$BITRISE_TEST_RESULT_DIR/<module>-<variant>/の下にコピーしてるだけ。 $BITRISE_TEST_DEPLOY_DIRサブディレクトリを指す環境変数だそうな。

さて step-info.jsonについてまるで触れられてないけどどういうことなんでしょう。この辺でもういいやってなって諦めた。

追記 (2019-06-16)

追記終わり

さいごに

多分 test の後に script step で JSONをいい感じに作ってテスト結果と一緒に配置すれば指定の test step を使わなくても Test Reports が使えるのだろうけど、調査だけで怠くなったのと完全にレールを外れたことしてるなって感じて、それを解決方法にしたくないという気持ちになったので試してない。

Androidで Test Reports を使いたい人は大人しく Android Unit Test を使おう、というべきところなのだろうけど、この step も問題があって、1 step で1つのモジュールに対してしかテストを実行できないし、project root 直下のモジュールしか認識できないのでグループ分けとかでもっと下の階層にモジュールが存在する場合は Android Unit Test でそれを実行できないし、例えば JaCoCo とかで coverage report を作成している場合はそれ用の task を実行してると思うけど Android Unit Test は モジュールと build variant しか指定できなくて test<Variant>UnitTestとか基本の test task しか実行できないので coverage report が欲しかったら別途 step を実行しなきゃできないとか、とにかく融通がきかない。

Android project で任意の gradle task を使ってテストを実行したい場合には Gradle Unit Test という step があるけど、当然こっちには Test Reports 向けの export 処理は入ってなくて、やっぱり欲しいって人はいて feature request が既に投稿されている。

discuss.bitrise.io

また、Flutter project 向けにも Test Reports に対応する request も今日投稿されていた。

discuss.bitrise.io

このような感じで、現状 Test Reports を活用するには Bitrise 側の準備というか状況が全然足りてなくて、みんながみんな自分の use case に応じた feature request を送ったり、これからも送られるだろうなという感じなのだけど、その要望に対してそれぞれの test step に Test Reports 向けの処理を埋め込むという解決法をとると、これまで作ったものにもそうだし、これから新たに test step が追加される場合にも逐一その処理を追加することになって手間だよなーと感じたし、現行の実装を見るにテスト結果を格納しているディレクトリの場所について限定された想定しかされてなくて融通効かなさそうなの嫌だなーって思ったので、独立した step にして project の種類や構成、使用してる step に依存しないようにしてほしいなーってことで私も feature request を投稿した。

discuss.bitrise.io

個人的には repository 中を適当に走査して、対応する format のファイルを全部 $BITRISE_TEST_RESULT_DIRに放り込んで、あとは必要な jsonを適当に生成してよしなに体裁を整えてくれれば良くて、例えば複数モジュールに対するテスト結果があったとしても、全部1つのグループにまとめられてしまっても構わないと考えてる。まあファイルの存在するディレクトリの情報を取っておいて、それを元に metadata とか格納先ディレクトリを区別するとかでできるのかもしれないけど。

この機能周辺に関しては悪い意味での easy に寄ったスタートしてるなーって感想で、触っててストレスしか感じないような現状なので早く使いやすくなってほしい...


Deploy to Bitrise.io で生成した artifact のインストールページの QR code を作る

$
0
0

社内に需要があったので

調べればこれについて言及のある記事がいくつも出てくるけど、だいたいが一連のビルドフローに関する解説記事の一部で触れられているという感じで目的の情報に辿り着くのが手間な状況なので QR code だけにフォーカスした記事を作ろうという動機で書いている。

手順は大まかに以下の2 (or 3) ステップ:

  1. Deploy to Bitrise.io で public install page を生成
  2. Create install page QR code で 1. で作ったページのURLの QR code を生成
  3. QR code の画像URL $BITRISE_PUBLIC_INSTALL_PAGE_QR_CODE_IMAGE_URLを任意の用途に使用する

Deploy to Bitrise.io で public install page を生成

Workflow Editor でいうと "Enable public page for the App?", bitrise.ymlでいうと is_enable_public_pagetrueにする。1.6.0現在 default true なので、自分で falseにしてあるのでなければそこの設定をいじる必要はなし。

Create install page QR code で public install page URL の QR code を生成する

github.com

これ

生成対象が default で $BITRISE_PUBLIC_INSTALL_PAGE_URLになってるので追加するだけで良い。必要であればお好みで生成される画像のサイズを調節することができる。

生成された QR code を使う

上記のステップによって $BITRISE_PUBLIC_INSTALL_PAGE_QR_CODE_IMAGE_URLQR code の画像URLが設定されるので適当に使えばOK。例えば Slack にビルド結果を通知しているのであれば、 "A URL to an image file that will be displayed inside the attachment" (image_url) にこの環境変数を設定すれば通知されたメッセージに QR code が表示されるようになる。

bitrise.yml

- deploy-to-bitrise-io:inputs:- deploy_path:"<path to your artifact>"- create-install-page-qr-code:{}# Slack 通知で使う場合- slack:inputs: # other configurations...- image_url:"$BITRISE_PUBLIC_INSTALL_PAGE_QR_CODE_IMAGE_URL"

Bitrise で Android instrumentation test を実行させるための調査メモ

$
0
0

最近 Androidアプリの instrumentation test を実行する workflow を構築する機会があったので、そのために当たった情報やついでに気になって調べたことなどを覚書も兼ねて記事にすることにした。

目次

Steps

必要なのは以下の2つ:

基本的に何を設定すればいいかは公式のドキュメントを読んで組み立てればOK。

devcenter.bitrise.io

Virtual Device Testing for Androidで instrumentation test の設定をする

実行するテストを指定する

Workflow Editor の Virtual Device Testing for Androidには Instrumentation Testというセクションがあって、 instrumentation test のための設定がいくつか行える。開くと以下のキャプチャのような感じ。

f:id:nashcft:20191207193823p:plain

この中の Test targets, seperated with the "," character.という項目で実行するテストを指定することができる。指定の方法は3種類あって、editor の記述を持ってくると

  • package package_name
  • class package_name.class_name
  • class package_name.class_name#method_name

というようにパッケージ単位から特定のテストメソッドまで指定することができる。上のキャプチャではクラス単位での指定をしているが、例えばパッケージでまとめるなら package jp.nashcft.uitest.example、テストケース単位だったら class jp.nashcft.uitest.example.UITest1#fooTestというようにすれば良い。

これによって特定の文脈では一部のテストだけ実行する、みたいなことができ、例えば Room のスキーマを変更する際に特別な branch 名を使うようにして、その branch に対する workflow では migration test だけを実行対象にした instrumentation test の step を設定する、といったことが考えられる。

また、項目名にあるように、複数の実行対象を指定する場合は ,で区切れば良い。これにはちょっとした罠があって、多数指定する場合、可読性のために改行を挟みたくなることがあると思うが、,の後に改行を入れてしまうと最後の行で指定された対象以外は実行対象として認識されなくなってしまう。なので、一切改行を入れずに羅列するか、どうしても改行したい場合は classpackageの後の whitespace で改行すると全ての対象を認識してくれるので、そのように回避する必要がある。とはいえ Workflow Editor 空記入するにしても bitrise.ymlに直接書き込むにしてもどうせコピペ運用になると思うのでそこまで気にしなくても良いかもしれない。余談だが、この挙動について認識しているのか editor で改行せず実行対象を羅列して保存し、 bitrise.ymlを見ると、class / packageの後で改行しながらの記述になっている。

Test Reports で実行結果を見る

Test Reports そのものについては公式のドキュメントがあるのでそちらを参照してほしい。

devcenter.bitrise.io

Test Reports で確認できるものは、各テストケースの実行結果の他に、テスト実行時の録画やスクリーンショット、ログ、CPU/RAM/Network のプロファイルなどがある。

ところで今回構築していた時に気づいて確認したことだが、Virtual Device Testing for Androidを使用した場合、そのあとで Deploy to Bitrise.io を実行しなくても Test Reports に実行結果がアップロードされている。内容的に先に UI testing のページを読んでいた場合には別に当然という認識になるのだが、私は以前 Test Reports の方だけ調べていた時期があって、そちらのページには (今でも) test step 実行後に Deploy to Bitrise.io を実行すると結果をアップロードしてくれると書いてあったので、deploy 忘れてるのに Test Reports が見られたので少し驚いてしまった。

既知の問題

Android library の AndroidTest build が失敗する

Android library は assemble すると aar を生成するが、 Android Build for UI Testing は成果物に apk だけを想定しているため、 step が失敗してしまう、という issue があがっている。

github.com

こちらに関しては Bitrise の中の人に認知されていて、 forum の方で feature request が作成されている。この feature request は vote の数が多くなると priority の高い項目として対応されやすくなるので、関心のある人や対応されないと困るなあという人はログインしてこの request に vote しておくと良い。

discuss.bitrise.io

現状でなんとか Android library の instrumentation test を実行したいという場合は、多分 script step で自分でコマンド指定してビルドして、成果物の出力先パスを Virtual Device Testing for AndroidApp pathTest APK pathに渡せば動くかもしれない。これはまだ試していないので実行できるかは不明。

Nested module はビルド対象として認識されない

これは Android系 build step 共通の問題なのだが、現状 project の root directory にある module しかビルド対象として認識されず、存在しない module を指定しているとしてビルドが失敗する。

例えば以下のような project があるとする:

/
+ app
|  + build.gradle
+ features
  + featureA
  |  + build.gradle
  + featureB
     + build.gradle

この場合、step に認識される module は appだけで、 featureディレクトリ以下の各 module は検出されない。

この問題については原因となるパッケージに対して既に修正のPRが出されていて merge もされており、これに依存している各 step のレポジトリも依存を更新しているので、次回のバージョンアップで解決するはず。

調べてないこと

  • Instrumentation test の設定の Use Orchestratorって何
    • 多分以下のドキュメントに書いてある設定のことだと思うけど、外部から設定できるんだなって

developer.android.com

先の話、願望の話

前述した nested module を認識しない問題と Android Build for UI Testing (と、もしかしたら Virtual Device Testing for Androidも) の aar 対応がリリースされたら feature module などの instrumentation test 用 workflow を構築するつもりだ。これについて既に見えている課題として、

  • それぞれ 1 step あたり1つの module しか対象にできない
  • 現状 Virtual Device Testing for Androidが1ビルドあたり1 step しか設定できない

というのがある。これらを解決する一番単純な方法は対象となる module の分だけ workflow を作って Bitrise Start Buildで並列化して繋いで実行するという感じになるが、数個だけならまだやるとしても既に数十個の module に分割されているアプリに対してそれをするのは作業する気にもなれないし、仮にやったとして、1回実行しただけで queue がパンクするんだよなあ... となる。Queue の方は per concurrency plan から Pay as You Go plan に変更すれば一応解決するが、財力の問題が当然ある。

まあ Pay as You Go plan にするにしてもそうでないにしても、こういう方針でやるなら全ての module に対してまとめてビルドを行って、artifact をテスト実行コンテナにバラバラっと配って、そちらでそれぞれの module の instrumentation test を実行する、というような構成ができたら総ビルド時間も短くなるし嬉しいんだよなという思いがある。これは実質 CircleCI の workflow による job の pipeline 化と1つの workflow 内での workspace の共有と同じような発想なのだけど、Bitrise でも build router start で workflow の並列実行ができるようになっているのだしこういうことが Bitrise でもできるようになってほしいなあって期待してる。

circleci.com

もしくは Android Build for UI Testing が variant だけ適当に指定したらそれにヒットする module のビルドを行ってくれるようになって、Virtual Device Testing for Androidが複数のテスト対象を 1 step でまとめて実行してくれるようになってくれたら、というアイデアもある。こっちの方がユーザ的にはやることが少なくなって楽だけど、任意の数の module に対して成果物を取得してそれぞれに対して Firebase Test Lab (もしくは何らかの device farm) でテストを実行して結果を収集して Test Reports にアップロードするというのを実現するのはしんどそうだなあと思う。

終わりに

Androidの instrumentation に必要な2つの step は、Android Build for UI Testing は v0.1.1, Virtual Device Testing for Androidは step 名に [BETA]とついていることから察せるようにまだ発展途上というか機能的に物足りなく、単純に apk のテストをすることしかできないが、単にいくつかの設定を記入するだけでUIテストを実行してくれる手段を公式で提供してくれているというのは他のCIサービスにはない強みだと思うので、今後のバージョンアップに期待したい。

参考資料

Android: アプリからメールアプリを開く時の Intent の設定とメールアプリの挙動について

$
0
0

以下の tweetに関する話。

発端

開発中のアプリでアプリ内から件名や本文テンプレート入りのメールを送る機能を実装して人に見てもらったら件名と本文が出ないんだけどって報告が来て、調べてみると件名や本文が送る内容によって出たり出なかったりした。あと報告で使われてたアプリが Gmailだったのだけど Outlookで開いてみたらどのメールもちゃんと件名と本文が反映されてることがわかった。

その時のコードの雰囲気と挙動

大雑把に問題の箇所を取り出すと以下のような感じ:

fun createEmail() {
  startActivity(createMailToIntent(address, subject, text))
}

fun createMailToIntent(
  address: String?,
  subject: String,
  text: String
): Intent {
  val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${address ?: ""}")).apply {
    putExtra(Intent.EXTRA_SUBJECT, subject)
    putExtra(Intent.EXTRA_TEXT, text)
    flags = Intent.FLAG_ACTIVITY_NEW_TASK
  }
  return Intent.createChooser(intent, "Select an app."))
}

作成するメールの種類と Gmail, Outlookの挙動を確認すると、送信先メールアドレスの有無が関係しているようだった:

  • メールアドレスなし
    • Gmail ->件名・本文が反映される
    • Outlook ->件名・本文が反映される
  • メールアドレスあり
    • Gmail ->件名・本文が反映されない
    • Outlook ->件名・本文が反映される

調べてわかったこと

Gmailでは Intent に与える data (URI) の mailto:の後にメールアドレスが続くと、 extra で指定したパラメータを無視するという挙動をとるらしいことがわかった。そこで、data ではメールアドレスを指定せず mailto:のみとし、かわりに Intentがデフォルトで持っている key の一つである EXTRA_EMAIL送信先を指定するようにしたところ、 Gmailでも送信先、件名、本文全てを作成するメールに反映してくれるようになった。

developer.android.com

この設定で Outlookを選択しても同様に送信先と件名、本文を反映してくれたので、今回はとりあえず extra で指定する方法に修正することにした。

この後ちょっと気になってURIと extra の両方でメールアドレスを設定したときに Outlookはどう処理するのかというのを試してみたところ全部作成メールに反映してくれた。なのでこの場合 mailto:の後のアドレスと EXTRA_EMAILで指定したアドレスの2つが to: に入力されてしまい、まあ意図的にこういう送信先設定はしないよねということで使い道はなさそう。

修正後のコード

fun createEmail() {
  startActivity(createMailToIntent(address, subject, text))
}

fun createMailToIntent(
  address: String?,
  subject: String,
  text: String
): Intent {
  val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:")).apply {
    putExtra(Intent.EXTRA_EMAIL, arrayOf(address ?: ""))
    putExtra(Intent.EXTRA_SUBJECT, subject)
    putExtra(Intent.EXTRA_TEXT, text)
    flags = Intent.FLAG_ACTIVITY_NEW_TASK
  }
  return Intent.createChooser(intent, "Select an app."))
}

おわりに

ここまで書いてから Android Developer 見に行ったら普通にこう書けよって解説あったのでちゃんとリファレンス読んで実装しようねってなった。

developer.android.com

それはそうと挙動確認用のサンプルコードがあるので、GmailOutlook以外のメールアプリを使用した場合の挙動はどうなるの? というのが気になったらお使いください。

github.com

2019年振り返り

$
0
0

記録として

転職した

nashcft.hatenablog.com

Twitterでみるような人たちと面白おかしく Androidアプリ開発をやっている。
一つだけ悩みを挙げるとするなら、前職では途中からフルフレックス制になって、電車の混雑を避けるために11~12時出勤みたいなことをずっとしていたので、それに慣れてしまった身体には今の10時出勤は大変厳しいというのがある。

オフィス移転した

面談の時から話に聞いていたのだけど入社して1ヶ月でオフィス移転したことになる。
駅から遠くなってしかもセンター街を通り抜けなければならないので人を呼ぶにはしんどいところだけど電車通勤じゃない身としてはむしろ通勤が平和になって良かった。

入籍した

話がまとまってからいろいろ都合が良かったのが4/1だったので入籍日はそのようになった。

旅行行った

伊勢と伊東に行った。また温泉行きたい。

読書

転職してから読書ペースがガタ落ちして、前職にいた2月までの読了数と3月以降の読了数がほとんど一緒という結果に。積読タワーが大変なことになってきたのでなんとか読む時間を確保したい...

今年読了した中で特に面白かったのは、仕事関係では "Joel on Software"で、もはや古典と言える程度には内容の具体的な部分は古くなってしまっているのだけど、主張や考え方は今でも通じるものがあると思うし、あと単純にエッセイとして面白い。
趣味方面だと人に勧められて読んだコンラッドの『闇の奥』が面白かった。

2020年にやりたいこと

引っ越したい。

bitrise.io における workflow の差分管理

$
0
0

昨年末に Bitrise Advent Calendar 2019を眺めてたら、以下の記事で bitrise.io 上で bitrise.ymlの差分管理ができるって書いてあって、気になって調べてみた話。

qiita.com

記事には差分管理の機構について紹介がなかったので、"bitrise yml diff"とか "bitrise yml version control"とかそんな感じで検索してみたら、Bitrise 公式ページの Features > Workflow editor に以下の記述があり、どうやらビルド単位で workflow のスナップショットをもっているっぽいことがわかった。

Bitrise saves each build’s configuration state and if changed you can check out a diff between the older and the new versions. Something seems off? Click restore to roll back to an earlier version.

www.bitrise.io

それからもう少し調べてみたところ、bitrise.io 上での bitrise.ymlの管理について書かれたドキュメントに行き着いたので完全理解した。Bitrise はコアの部分のドキュメントはちゃんとしているので公式ドキュメントにたどり着いたら実質ゴールである。この調子で各 step でできることのドキュメントも充実させてほしい。Step の振る舞いを理解するのに毎回レポジトリを探してコードを読むのは何か間違っている気がするので。

devcenter.bitrise.io

ドキュメントによると、できることは以下の3つとのこと:

  • 各ビルドの実行で使われた bitrise.ymlのスナップショットの確認
  • 「現在の」 bitrise.ymlとの差分の表示
  • そのスナップショットへのロールバック

あくまで「変更履歴の管理」じゃなくて「現在との差分管理」なんだなあと思わずにはいられないが、CIの workflow にそんなリッチな変更管理はいらないのかもしれない。でもたくさんのビルドが間断なく実行されるような開発環境で変更前のビルドを掘り出すみたいなケースを想像すると、やっぱり「いつ・どんな」変更をしたかをビルドとは独立して記録しておいた方が便利じゃないかなあって思った。

最初は調べてみて良い感じだったらレポジトリ管理から乗り換えようかと考えていたけど、自分の求めているような機能ではなかったのと、レポジトリで bitrise.ymlを管理する理由って、開発の並列度の関係で新しい workflow の実験中は変更の適用範囲を限定したいとか、レポジトリの状態 (ツールのバージョンとか build script の変更とか) と常に同期させたいとかも含まれてて、変更管理だけではないなあということに気がついたので、結局これからもレポジトリで bitrise.ymlを管理し続けるだろうという結論になった。おわり。

Viewing all 97 articles
Browse latest View live