言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

小規模もくもく会運営についての知見とポエム

この記事は勉強会運営 Advent Calendar 2017の19日目の記事です

adventar.org

はじめに

会社でもくもく会を運営しているが今年はついに20回を超え、ありがたいことに他社からも「御社のもくもく会パクリ 参考にしてうちでももくもく会はじめました 」と言ってもらえることが増えた。勉強会運営としてはある程度いい感じに回っている気がするので誰かの参考になればと知見とポエムを書き残しておきたいと思う。

注意事項

上記で「いい感じに回っている」と記載したが、それは健全な勉強会コミュニティを形成すると言う意味であり、採用目的(「いいエンジニアをシュッと採用したい」など)の観点ですぐに効果が出ることを期待しているのであれば、おそらくこの記事はあなたの求めているものではない。

Androidもくもく会@Rettyオフィス」の歴史

私が運営しているのは「Androidもくもく会@Rettyオフィス」というAndroidエンジニアを対象としたもくもく会である。この勉強会はもうすぐ2年ほど経つが、2年間の間に色々模索しながら形態を変えてきた。 形態によって大きく分けて3つの時期に分けられ、それぞれどのような学びがあったかをまとめたいと思う。

1. 「もくもく + 懇親会」期

そもそもこの勉強会自体は私がはじめたものではない。(第1回目は参加すらしていない。)もともと他社のエンジニアに弊社のことを知ってもらったり、業界のエンジニアと知り合いたいと言う軽い採用的な目的もそれなりにあった時期だったと思う。そのため、もくもくした後はケータリングの軽食とドリンクを提供した懇親会をやっていた。この時期の運営は別の人がやっており、私の役割は当日もくもく会のホストを務めるくらいだったと思う。ホストとして何をやっていたかというと、会の進行のみである。会の規模も定員10名程度とかなり小規模なものだ。最初に会場の案内をして、参加者全員に自己紹介と今日やることを一言ずつ言ってもらい数時間ひたすらもくもく。時間が来たら成果発表を簡単にしてもらって後は懇親会に移行する流れだ。

connpass.com

今見てみると不思議なことに第1回から参加者も豪華だ。(なぜみんな参加してくれたかは謎。)

ただ、この時期は直接的な採用の実績はなく、しかも運営側の負担が大きかった。(具体例をあげると、ケータリングの調整や平日開催だったため割と遅い時間まで拘束されるなど。)これはおそらく弊社がグルメサービスを運営している会社であり、ケータリングもかなり気合を入れる文化があったので特殊だったかもしれない。

何度か開催した後、コストがかかる・開催に関わる人の負担が大きいこと、後は現場のエンジニアが運営していないと言う理由でこの形態はやめた。特に後者は定期的に開催できない理由の大きな原因だったように思う。これはいい学びだったので、次からは主催を自分に移してもらうようにした。

2.「もくもくのみ」期

反省を生かし、この時期は「なるべく負担が少なく、かつ本当にやりたいことだけをやる」にフォーカスしていた。本当にやりたいこととは、勉強会コミュニティの運営とした。 ケータリングの発注はなし、作業しながら飲めるドリンクだけ用意していた。後は本当にもくもくするのみ。だってもくもく会だもの。ただし参加者の自己紹介だったり、何をしているかなどのコミュニケーションは取れるようにしていた。

この時期に学んだことは、「もくもく会で軽食の用意はなくても来る人はくるし、来ない人は来ない」である。あと、懇親会は苦手だというエンジニアはいると思うので、もくもく後の懇親会は逆に参加のハードルをあげている部分もあったのではと振り返って思う。

「もくもく + 質問コーナー」期

第9回くらいに転機が訪れた。

retty.connpass.com

この日は台風16号の影響で天気が荒れており当日参加者は2人しか来なかったのだが、この2人がAndroidを始めたばかりという特殊な回だった。2人とも周りにAndroidのことを聞ける人がおらず、もくもく会というよりは質問会になったのだが、それがとても楽しかった。

Androidを始めるとだいたいみんなぶつかるであろう「FragmentとActivityのライフサイクル」など、"ちょっと経験のある誰かに聞いてみたいこと"が次々に飛び出して来て盛り上がった。懇親会とはちょっと違う空気感で、ただただ技術について話すのはとても楽しかったのだ。だってエンジニアだもの。

これが楽しかったので次回からもくもくした後に「せっかくの機会なので他社のエンジニアに聞いてみたいことコーナー」を作るようになった。意外とほぼ毎回のようにいろんな質問が飛び出して来るのでやってよかったなと思う。この形態にしてから参加者の方から「うちでも同じフォーマットでもくもく会始めました」と言ってもらうことが出て来て、参加者側から見ても満足してもらえていると思っている。

他社への影響

観測している範囲で3社ほど弊社のフォーマットでもくもく会を始めてくれている。一時期もくもくコンサルでお小遣い稼ぎしたいと思ったけど、一度参加すれば簡単に真似できるフォーマットなので諦めた :P

robust-inc.connpass.com

oi-study.connpass.com

eure.connpass.com

気をつけていること

もくもく会を運営していくにあたって、いくつか気をつけていることがある。

日程について

日程はとても大切で、ポイントとしては大きな勉強会とかぶる日程は避けるという点である。potatotipsやshibuya.apkとうっかり日程を被せてしまった時は本当に人が来なかった。ターゲット層が多数参加するような勉強会が開催される日は避けた方良い。

Twitter ハッシュタグについて

「もくもくのみ」期あたりから勉強会用にTwitterハッシュタグを用意した。静かにもくもくしている中で声をあげづらいけど聞きたいことやエアコン下げてほしいなどの要望を掬うことが当初の目的だったが、意外といい副作用があった。Twitter界隈で広くエンジニアと繋がっている人が勉強会の広告塔になってくれるのだ。これは他の勉強会でも同じだと思うが、「〇〇さんがいつも楽しそうにつぶやいているので一度参加してみたかった」と言われた時は嬉しかった。

ちょっと失敗したと思うのはAndroidらしくアッパーキャメルケース (#AndroidMokuMokuRetty) でハッシュタグを作ってしまったこと。一般的に広く使われるスネークケースにすればよかった。しかも上記であげている他社のもくもく会もみんなこれに倣ってアッパーキャメルケースにしてくれておりなんだか申し訳ない。

余談だが、メルカリのAndroidもくもく会ハッシュタグは#mokumoku_android でだいぶ攻めたなと思っている。さすが岡野さん…

さいごに

これだけ思い入れのある勉強会だが、おそらく私が主催するのは多くて後2回ほどである。この記事を読んで興味を持った人がいたらぜひ参加してほしい。(募集人数は随時増やせるので枠は気にせずとりあえず参加ポチってください。)久しぶりの人もお別れ会だと思ってぜひ参加してほしい。

retty.connpass.com

Custom View を作るときに気をつけること

Custom Viewの背景画像を変えたいときにちょっとやらかしたのでメモる٩( 'ω' )و

何が起こったか

Custom Viewを作って動的に背景画像を変えようとして切り替わらない(ように見える)現象に遭遇。
レイアウトファイルで指定していた背景をコードから動的に切り替えようとしたときに、なぜか切り替わらないように見える 😇 何がおかしいか皆さんも考えながらコードをご覧ください。

コード

レイアウトファイル (layout_custom.xml)

静的に赤丸のdrawableを背景に指定しています。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:orientation="vertical"
        android:background="@drawable/red_oval"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

</LinearLayout>

CustomViewの本体コード (CustomView.kt)

初期化時に上記のレイアウトファイルを読み込んでいます。viewの生成が完了し、attachされたタイミングで動的に背景を黒丸に変更しています。

class CustomView : LinearLayout {
    constructor(context: Context?) : super(context)
    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs,
                                                                                    defStyleAttr)
    init {
        LayoutInflater.from(this.context).inflate(R.layout.view_custom, this)
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        this.setBackgroundResource(R.drawable.black_oval)
    }
}

現実

さて、上記コードを書いたときに私は背景は黒丸が出ることを期待していましたが、実際には赤丸が表示されています😩

f:id:muumuumuumuu:20171029162115p:plain:w200

何がダメだったのか

さて、ここまで読んでくれた皆さんは原因に気がついたでしょうか。
今回の原因はCustom Viewでlayout fileをinflateするときにrootを this に指定しているため、this = Custom Viewクラス (Linear Layoutを継承しているクラス)のchildとしてlayoutファイルからinflateしたviewたちがぶら下がる構成になっていました。そのため、コードから this に対して黒丸に背景を変更しても、その上にいる赤丸を背景に指定しているview (layout fileの一番上の階層にいるview)の背景が重ねて描画されているため見た目的には何も変わらないと言うオチになっていました。

どうすればよかったか

ちゃんと正しいviewに対してアクセスして背景を変えてやりましょう。(本当はちゃんとviewにidつけたりすべきですがサンプルなので見逃してください😇)

override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    this.getChildAt(0).setBackgroundResource(R.drawable.black_oval)
}

これで無事黒丸背景になりました!٩( 'ω' )و

f:id:muumuumuumuu:20171029163326p:plain:w200

KotlinとReduxをAndroidアプリに導入した話をしました。

先日自社のエンジニアイベントで「KotlinとReduxをAndroidに導入したら」という話をしてきました。質疑応答や懇親会でいろんな質問をいただいたのでここに残しておきます。

イベント

登壇したのはこちらのイベント。アプリだけでなくバックエンドやインフラ、果ては機械学習まで幅広いテーマを扱うイベントでした。聞き手の皆さんの知識もばらつきがありそうなので、割と丁寧にいろんなことを説明したつもりです。しかしその分内容を詰め込みすぎて早口に…

retty.connpass.com

当日の雰囲気はこちらで。

togetter.com

登壇内容

内容はこちらのスライドをご覧ください。ちなみに、最後の方の時間があれば話そうと思っていたFluxの話は時間切れでしませんでした。

speakerdeck.com

スライドにも乗っていますが、今回初めて会社としてOSS Libraryを公開しました。こちらについてもいくつか質問をいただきました。

github.com

質疑応答や懇親会で話したこと

Reduxについて

Q: Stateの持ち方を途中で変更すると大変じゃないですか?

A: 今の所変更したいケースは出てきていないのでわかりません。変更してない理由としては、必要最低限の情報しかReduxにのせていないからです。別のアプローチとしては情報を綺麗に正規化して持たせるという方法もあります。今はどちらのアプローチが良いのか、AndroidiOSでそれぞれ試している最中です。

—–  

Q: API通信はReduxの図のどこでやるんですか?

A: スライドには入れてませんが、実際にはActionを生成するCreatorProducerというのがいます。ここで、APIの戻り値の情報を適切なActionに詰めます。CreatorProducerが生成したActionがStoreにdispatchされるようになっています。

—–  

Q: SharedPreferenceなどローカルに情報を退避する方法を採用しないのですか?

A: 採用するのもいいのですが、退避場所を含めると2箇所で情報を持つことになるのでReduxの原則から外れるのと、情報によって「(退避する・しないといった)管理の方法」がバラバラになるのを避けたかったので今の所採用していません。もしも退避するのであればmiddlewareでやるのがいいと思います。

—–  

Q: フロントエンドでReduxはやったことがあるけれど、アプリにReduxを取り入れる時のメリットがうまくイメージできなかったのですが、どういった点でよかったですか?

A: AndroidアプリはActivityやFragmentの双方向の情報のやり取りが非常に面倒臭いので、一箇所にデータをまとめて各自がそこを監視するというReduxはその点でよかったです。

—–  

Q: FluxではなくReduxにした理由は?

A: Fluxにしたい(=Storeを複数持ちたい)と思うタイミングが定期的にやってきますが、今の所Reduxにしています。Fluxにしたいと思ってしまう時は、だいたいActivity/Fragment間で情報の共有がしたいときなので、しばらく悩んだあとサボらずにinterfaceを書いています。もしかしたらそのうちFluxを試すかもしれません。

--ここから懇親会では話せなかったけど、書いてて思いついたので追記--

メモリマネジメントの観点でFluxにする可能性も将来的にあるかもしれません。  
一時的にかなり大きなサイズの情報を扱う必要が出てきた時に、
不要になったらそのStoreごと破棄してメモリを解放させる必要が出てきた場合などです。
今の所この点では困っていませんがサービスによってはあり得ると思います。

--ここまで懇親会では話せなかったけど、書いてて思いついたので追記--

—–  

Q: Reduxを始める時に、何を参考にするのがいいですか?

A: この動画がオススメです!AndroidではなくJS前提ですが、Reduxのポイントは抑えられると思います。

Kotlinについて

Q: 今Kotlinの割合はどれくらいですか?

A: Kotlin:Java = 1:2 くらいです。

—–  

Q: JavaとKotlinのコードが混在していて困ったことはありませんか?

A: 特にありません。強いて言うなら久しぶりにJavaのコードに手を入れなくてはいけない時に、「あ、Kotlinじゃないからこの書き方できない…」ちょっと残念な気持ちになるくらいです。

OSSについて

Q: 今回初めてOSS Libraryとして公開したと思いますが、どういった理由から公開に至ったのでしょうか?

A: 公開したLibraryはReSwiftというOSS Libraryをリスペクトして作ったものです。なので、OSSに帰したいという気持ちが作ったメンバーの中にありました。そういう経緯からOSS公開に至りました。

—–  

Q: OSSで公開することに対して、社内でハードルはありましたか?

A: 作った人が公開したいという気持ちがあれば基本的にそれを尊重したいというカルチャーだったので、ハードルは特にありませんでした。しかし、当然議論はありました。公開することしないことに対するそれぞれのメリットやリスクを比較して、公開に至りました。

おまけ

長々と語っていますが、実はまだストアに公開されているアプリにはほんの一部しかReduxを取り入れられていません… 早く綺麗に作って公開したい!

localeに繁體中文を設定してもvalues-zhが選択されない

今回もマニアックなトピックです。
タイトルの通り。ちょっと困ったので調べて見た。

何が起こったか

下記どちらかに当てはまる場合、

  • 端末で繁體中文を言語設定で選択している
  • アプリ独自のlocale設定でzh_HK(中国語-香港)を設定している

res 下の values-zh/strings.xml が読まれず res/strings.xml が選択された。

例えば、values/strings.xml と、

<resources>
    <string name="test">Test</string>
</resources>

values-zh/strings.xml がある場合、

<resources>
    <string name="test">測試</string>
</resources>

言語設定を繁體中文にしていても測試ではなくTestが表示される。同じ中国語(zh)ならvalues-zhが優先されると思いきや、そんなことない。
ここでvalues-zh/strings.xmlvalues-zh-rHK/strings.xml にすれば測試になってまあ当面困ることはないが釈然としない。

ちなみに確認環境はNexus 6P (OS 7.1.2) 実機。

N以降でおきてるっぽい

そういえばNからLocale周りで変更入ったな?🤔 というのを思い出した。

muumuutech.hatenablog.com

試しにEmulator (Nexus 5 / API 23) で動かして見たらvalues-zh/strings.xml が選択されて測試が表示された。 同じEmulator環境でもN系のものはだめ。(Nexus 6P / API 25とNexus 5X / API 23 で確認。)

一瞬Multi localeになった影響でEnglishがlocale listの二番目にきている影響か?と思ってEnglishを削除して見たけどだめ。 よく考えたらそもそもvalues-enを作ってないテスト環境でも再現したから影響しようがなかった。

Frameworkのコードを読んでみよう

N以降のFrameworkのコードを読んでみる。ResourcesImpl.java#320 あたりから見ていくと読みやすいかな。 この辺りのコードでbestLocaleを取ってきて設定しているっぽい。

    320     public void updateConfiguration(Configuration config, DisplayMetrics metrics,
    321                                     CompatibilityInfo compat) {


    351                 // If even after the update there are no Locales set, grab the default locales.
    352                 LocaleList locales = mConfiguration.getLocales();
    353                 if (locales.isEmpty()) {
    354                     locales = LocaleList.getDefault();
    355                     mConfiguration.setLocales(locales);
    356                 }
    357 
    358                 if ((configChanges & ActivityInfo.CONFIG_LOCALE) != 0) {
    359                     if (locales.size() > 1) {
    360                         // The LocaleList has changed. We must query the AssetManager's available
    361                         // Locales and figure out the best matching Locale in the new LocaleList.
    362                         String[] availableLocales = mAssets.getNonSystemLocales();
    363                         if (LocaleList.isPseudoLocalesOnly(availableLocales)) {
    364                             // No app defined locales, so grab the system locales.
    365                             availableLocales = mAssets.getLocales();
    366                             if (LocaleList.isPseudoLocalesOnly(availableLocales)) {
    367                                 availableLocales = null;
    368                             }
    369                         }
    370 
    371                         if (availableLocales != null) {
    372                             final Locale bestLocale = locales.getFirstMatchWithEnglishSupported(
    373                                     availableLocales);
    374                             if (bestLocale != null && bestLocale != locales.get(0)) {
    375                                 mConfiguration.setLocales(new LocaleList(bestLocale, locales));
    376                             }
    377                         }
    378                     }
    379                 }

では「何がbestなのか」を評価するためのロジックはどうなっているかというと、LocaleList.java#302 あたり。

    302     @IntRange(from=0, to=1)
    303     private static int matchScore(Locale supported, Locale desired) {
    304         if (supported.equals(desired)) {
    305             return 1;  // return early so we don't do unnecessary computation
    306         }
    307         if (!supported.getLanguage().equals(desired.getLanguage())) {
    308             return 0;
    309         }
    310         if (isPseudoLocale(supported) || isPseudoLocale(desired)) {
    311             // The locales are not the same, but the languages are the same, and one of the locales
    312             // is a pseudo-locale. So this is not a match.
    313             return 0;
    314         }
    315         final String supportedScr = getLikelyScript(supported);
    316         if (supportedScr.isEmpty()) {
    317             // If we can't guess a script, we don't know enough about the locales' language to find
    318             // if the locales match. So we fall back to old behavior of matching, which considered
    319             // locales with different regions different.
    320             final String supportedRegion = supported.getCountry();
    321             return (supportedRegion.isEmpty() ||
    322                     supportedRegion.equals(desired.getCountry()))
    323                     ? 1 : 0;
    324         }
    325         final String desiredScr = getLikelyScript(desired);
    326         // There is no match if the two locales use different scripts. This will most imporantly
    327         // take care of traditional vs simplified Chinese.
    328         return supportedScr.equals(desiredScr) ? 1 : 0;
    329     }

まず完全にLocaleが一致するか見ている。ここは一致しないはずなので次へ。
次にLonguageが一致しないか見ている。ここは一致するので次へ。
次にPseudo Localeか見ている。ここは当てはまらないので次へ。
最後ににscriptが一致するかを見ている。ISO15924で繁体字Hant と定められていて、これがいわゆる中国語の簡体字Hansとは別扱いだった。 わざわざコメントでも書いてあった。

There is no match if the two locales use different scripts. This will most imporantly take care of traditional vs simplified Chinese.

まとめ

というわけで、values-zh簡体字扱いなので、端末の設定を繁體中文(繁体字)にしても文字が違うということで選択されなかったという結論になった。スッキリ!

LocaleといえばLanguageとCountry (region) くらいしか気にしていなかったけれど、script(文字) も気にしないといけなかった。 Referenceにも当然だけどscriptのことが書いてあった。ただ、これだけだとscriptがmatchingのロジックにどう関わるかわからないんじゃないかなぁ… というわけでたまにはFrameworkのコード読むの大事!٩( ‘ω’ )و

Locale | Android Developers

Kotlin初心者から抜け出したい?それなら、

Land of Lispを読んだらどうだろう。 (すみません。9割ネタです。)

Land of Lisp

Land of Lisp

Kotlinを初めて数ヶ月だったあなたへ

今年のGoogle I/OでKotlinがAndroidで正式サポートされるとアナウンスされた頃からでしょうか、Kotlin初心者向けの勉強会が一気に増え、実際に自分の周りもAndroidエンジニアを中心にKotlinを触る人が増えてきました。
最初はみなさんこちらの赤べこ本から始める人が多いかと思います。

Kotlinスタートブック -新しいAndroidプログラミング

Kotlinスタートブック -新しいAndroidプログラミング

(英語が得意な人であればKotlin in Actionもいいですね)

Kotlin in Action

Kotlin in Action

赤ベコ本、初心者でもとっつきやすくて本当に素敵な入門書です。ただ、入門書も読み終わって初心者からそろそろもう一歩進みたいそこのあなた。

Java(特にAndroid Java1 )をずっと続けてきてからKotlinに移行した人、そんな人におすすめなのが Land of Lisp!

なぜLand of Lispがおすすめなのか

一体どこからLispが出てきたのか?この本は半分Lispについての本ですが、 半分は関数型プログラミングについての入門書です。
Kotlinは関数型プログラミングパラダイムを取り入れています。

例えば、高階関数。公式ドキュメントを読む時も高階関数(Higher-Order Functions)とかさらっと出てきます。それが一体なんなのか、なんのためにあるのかちゃんと理解していますか?

例えば、遅延評価。Java 8が使えないAndroidでは初めましての人も多いかと思います。Kotlinではvalで再代入不可な変数を宣言することができますが、これをpropertyで持つとなると当然初期化をする必要があります。クラスのインスタンス化時点では取れない情報を使って初期化したい時、遅延評価が本当に強力な機能となります。
Androidの例で恐縮ですが、Activityのpropertyとしてintentのextraを持ちたい場合、下記のようなコードはかけません。なぜならクラスのインスタンス化時点でthis.intentは正しいintentが入っていないからです。

class HogeActivity : AppCompatActivity() {

    private val fuga = this.intent.getStringExtra(FUGA_KEY) // 動かないよ!

}

そこでvalで宣言するのを諦めてvarにして書けないことはないのですが、イマイチなコードです。

class HogeActivity : AppCompatActivity() {

    private var fuga = "" // 動くけどイマイチだよ!

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        this.fuga = this.intent.getStringExtra(FUGA_KEY)

    }

}

そこで、下記のように遅延初期化すれば解決します。このコードの場合、初めてfugaにアクセスしようとした時にfugaが初期化されます。なので、例えばonCreate()fugaにアクセスするとこの時にはthis.intentに正しい情報が入っているのでfugaには想定通りの値で初期化されます。これでfugaをvalで宣言することができます。

class HogeActivity : AppCompatActivity() {

    private val fuga by lazy {
        this.intent.getStringExtra(FUGA_KEY) // 動くよ!
    }

}

おまけ

そもそもなんでLand of Lispなんか読み始めたかというと昨年の誕生日に同僚氏が自宅に送りつけてきたからです。(当然wishlistには入れていません。)今まで5人くらいに送りつけて実際に読み始めたのは私が初めてだそうです😇
読了して思ったのは「この本やっぱり頭おかしいな」でした。

最後に。この本を読んで副作用満載のコードをみると発狂しそうになるかもしれませんが責任取れませんので悪しからず。


  1. Android Javaは長いことJava本流から取り残されてJava8の便利なあれこれが使えず悲しい気持ちになりますね

Dexopenerを使ってKotlinで書かれたfinalなclassをmockする話

[2017.7.27 追記あり]

4ヶ月以上前の話の続きです。下書きにずっと眠っていたのですが、諸事情により公開が遅くなってしまった…

こちらの記事でAndroidでKotlinのmockつらい〜〜〜 😇😇😇😇って話をしたら、

muumuutech.hatenablog.com

id:tmurakami さんがコメントでDexOpenerを教えてくださったので試してみました。 (id:tmurakamiさんがDexOpenerの作者さんです。)

github.com

使い方

上記RepositoryのREADMEにある通り、testコードのcompile時に対象libraryを指定して、 testInstrumentationRunner を指定するだけです。

dependencies {
    androidTestCompile 'com.github.tmurakami:dexopener:0.9.1'
}
android {

    defaultConfig {
        testInstrumentationRunner "com.github.tmurakami.dexopener.DexOpenerAndroidJUnitRunner"
    }

これだけでfinal classをmockできるようになります!楽!!

中で何が起こっているのか

せっかくなのでlibraryの中身を読んでみました。Open source最高!!
こちらのlibraryは同じ作者さんの classinjector というlibraryに依存を持っているのでコードを読むときは手元にcloneして読むことをおすすめします。 読み間違いなどあったらすみません!ご指摘いただけると嬉しいです!

github.com

ざっくり概要

[2017.7.27 追記]
最新version( 0.10.3 )で方針が変わってかなり早くなりました!圧倒的…!
ただし、minSdkVersionが16にあがっていましたのでご注意を。

version time
0.9.1 Total time: 3 mins 37.83 secs
0.10.3 Total time: 9.162 secs

dexファイルを取って来て、下記のクラス・メソッドのアクセス修飾子からfinalをマスクしてbit反転していました。

  • class
  • inner class
  • method

というわけでクラスやメソッドが増えれば増えるほど処理に時間がかかります。

コード詳細

まずはTest Runner ( DexOpenerAndroidJUnitRunner.java ) のnewApplicationで DexOpener#install() をコール。 このメソッドの中で ApplicationInfo を持ったbuilderを生成し、 DexOpenerImpl に処理を移譲します。 この時、Builderに BuiltinClassNameFilter を設定して、buildinのclassはmockできないようにしています。

BuiltinClassNameFilter.java

    private final String[] disallowedPackages = {
            "android.",
            "com.android.",
            "com.github.tmurakami.classinjector.",
            "com.github.tmurakami.dexmockito.",
            "com.github.tmurakami.dexopener.",
            "com.github.tmurakami.mockito4k.",
            "java.",
            "javax.",
            "junit.",
            "kotlin.",
            "kotlinx.",
            "net.bytebuddy.",
            "org.hamcrest.",
            "org.jacoco.",
            "org.junit.",
            "org.mockito.",
            "org.objenesis.",
    };

ApplicationInfo.dataDircode_cache/dexopener でcacheを生成した後、 ClassInjector を使ってApplicationInfo からclasses*.dexファイルを取って来て、

AndroidClassSource.java

    @SuppressWarnings("TryFinallyCanBeTryWithResources")
    private ClassSource newDelegate() throws IOException {
        List<ClassSource> sources = new ArrayList<>();
        ClassNameReader r = new ClassNameReader(classNameFilter);
        ZipInputStream in = new ZipInputStream(new FileInputStream(sourceDir)); // sourceDirはApplicationInfoから取得したやつ
        try {
            for (ZipEntry e; (e = in.getNextEntry()) != null; ) {
                String name = e.getName();
                if (name.startsWith("classes") && name.endsWith(".dex")) {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    byte[] buffer = new byte[16384];
                    for (int l; (l = in.read(buffer)) != -1; ) {
                        out.write(buffer, 0, l);
                    }
                    ApplicationReader ar = new ApplicationReader(ASM4, out.toByteArray());
                    sources.add(dexClassSourceFactory.newClassSource(ar, r.readClassNames(ar)));
                }
            }
        } finally {
            in.close();
        }
        return new ClassSources(sources);
    }

[2017.7.27 追記]
コメントいただきました通り、最新版ではASMDEXではなくdexlib2を使う方針に変わっていました。失礼しました。

下記のfinalをbit反転しています。

  • class
  • inner class
  • method

visitorパターンで実装されていました。ASMDEXのClassVisitor を使っているためかな?

ApplicationOpener

    @Override
    public ClassVisitor visitClass(int access,
                                   String name,
                                   String[] signature,
                                   String superName,
                                   String[] interfaces) {
        int acc = access & ~ACC_FINAL;
        return new ClassOpener(api, super.visitClass(acc, name, signature, superName, interfaces));
    }

感想

本体側をKotlinで書かれているアプリのinstrumented auto testしたい〜〜って時に選択肢の一つとして良さそうです🎉🎉

[2017.7.27 追記]
パフォーマンスもかなり良いので、minSdkVersionが16以上という方はぜひ使ってみたらいいと思います!🎉🎉

Kotlin 1.1.2-5くらいからSAM変換がちょっと変わってcrashが起きた件

Kotlinのバージョンあげたら既存コードがcrashしたのでちょっと調べてみたメモ。

何が起こったのか

kotlin のversionを1.0.5-3から1.1.2-5にあげたら既存コードがcrashするようになった。具体的には、こんな感じのIllegalStateExceptionが吐かれる。

java.lang.IllegalStateException: invoke(...) must not be null

何が悪かったか

Kotlin側に渡すJavaで定義したFunction0のオブジェクトでinvoke()をoverrideした際にreturn nullしていたのが悪かった。Javaでいうvoidに当たるUnitのインスタンスをreturnすることで落ちなくなる。

        new SamConversionExperimentalKt().invokeFunctionFromKotlin(new Function0<Unit>() {
            @Override
            public Unit invoke() {
                doSomething();
                return Unit.INSTANCE; // return nullするとcrashになるよ
            }
        });

Android Studio(v2.3)だとinvoke()メソッドを補完するときにreturn nullで補完してくれるからいちいち手で書き換えないといけない_:(´ཀ`」 ∠):
Javaでいうvoidはkotlinのnull許容・非許容と離れた概念だからどっち返しても大丈夫でしょ、とか思ってたら思わぬところで痛い目をみる。

で、問題はなんだったの?

実はこのcrashが発生するのにいくつか条件があって、

  1. JavaでFunctionN系のinvoke()メソッドをreturn nullしてoverrideする
  2. 1で定義したインスタンスをkotlinのコードに渡す
  3. 2のkotlinのコードからさらにjavaのコードに渡して、invoke()ではなくSAM変換後のメソッドとして実行する

3がちょっとなんて言ったらわからないのでコードを交えて説明。

適当なAndroidのコードで試したものです。まずはFunction0型のインスタンスをkotlinのコードに渡します。

public class SamConversionExperimental {

    public void invokeFunction() {
        new SamConversionExperimentalKt().invokeFunctionFromKotlin(new Function0<Unit>() {
            @Override
            public Unit invoke() {
                doSomething();
                return Unit.INSTANCE; // return nullするとcrashになるよ
            }
        });
    }

    private void doSomething() {

    }
}

その後、受け取ったkotlinコード側でSAM変換がかかるような感じで実行してやればcrash. 手元だとRunnable#run() が走るようにしてcrashさせた。
(Runnableは単一メソッドrun()だけを持つインターフェイス

    fun invokeFunctionFromKotlin(function: () -> Unit) =
        Handler(Looper.getMainLooper()).post(function)

SAM変換変わったね?

エラー文から察するに、この辺りのチェックコードがversion上がって入ってきたっぽい。 github.com

    public static void checkExpressionValueIsNotNull(Object value, String expression) {
        if (value == null) {
            throw sanitizeStackTrace(new IllegalStateException(expression + " must not be null"));
        }
    }

で、このメソッドがどこから呼ばれているかというと、この辺り。

kotlin/RedundantNullCheckMethodTransformer.kt at a5620454fa2fef926b4ca35b95fdb46a44506211 · JetBrains/kotlin · GitHub

        private fun analyzeTypesAndRemoveDeadCode(): Map<AbstractInsnNode, Type> {
            val insns = methodNode.instructions.toArray()
            val frames = analyze(internalClassName, methodNode, OptimizationBasicInterpreter())

            val checkedReferenceTypes = HashMap<AbstractInsnNode, Type>()
            for (i in insns.indices) {
                val insn = insns[i]
                val frame = frames[i]
                if (insn.isInstanceOfOrNullCheck()) {
                    checkedReferenceTypes[insn] = frame?.top()?.type ?: continue
                }
                else if (insn.isCheckParameterIsNotNull() || insn.isCheckExpressionValueIsNotNull()) { // ここにチェックが入っている
                    checkedReferenceTypes[insn] = frame?.peek(1)?.type ?: continue
                }
            }

            val dceResult = DeadCodeEliminationMethodTransformer().removeDeadCodeByFrames(methodNode, frames)
            if (dceResult.hasRemovedAnything()) {
                changes = true
            }

            return checkedReferenceTypes
        }

なんかこの辺りのcommitでcheck増えてそうな気配を感じるのでこれかなぁ

github.com