GitHub ActionでAndroidのCIを設定してみた
ベータを申し込んだら1日で有効になったので使ってみた
設定
ひとまずPR時にUTとlintを並列で動かしてみる。
name: Android CI on: pull_request: branches: - develop jobs: unit_test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - name: set up JDK 1.8 uses: actions/setup-java@v1 with: java-version: 1.8 - name: Decode google-services.json env: GOOGLE_SERVICE: ${{ secrets.GOOGLE_SERVICE}} run: echo $GOOGLE_SERVICE | base64 --decode > ./app/google-services.json - name: Unit Test env: TZ: Asia/Tokyo run: ./gradlew testDebugUnitTest lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - name: set up JDK 1.8 uses: actions/setup-java@v1 with: java-version: 1.8 - name: Decode google-services.json env: GOOGLE_SERVICE: ${{ secrets.GOOGLE_SERVICE}} run: echo $GOOGLE_SERVICE | base64 --decode > ./app/google-services.json - name: Lint run: ./gradlew lintDebug
を.github/workflows以下に置くだけ。 google-services.jsonは一旦base64のデコードでお茶を濁す。Secretsはリポジトリ -> Settings -> Secret -> Add a new secretで追加できる。 タイムゾーンは環境変数TZを設定してあげればよい。
感想
まず無料でベータだから仕方ないかもしれないがCircleCIに比べて単純に遅い。無課金のCircleCIで ./gradlew testDebugUnitTest
が1分10秒ほど、 ./gradlew lintDebug
が1分30秒ほどだったのに対し、GitHub Actionでは ./gradlew testDebugUnitTest
が3分20秒ほど、 ./gradlew lintDebug
が3分40秒ほどかかった。個人プロジェクトなど時間にこだわらない用途であればいい選択肢だと思う。無料で1リポジトリ辺り20並列まで使えるのはCircleCIやBitriseより遥かに上を行っているし、この程度の設定なら30分くらいで設定できた。実際のプロダクトで使うかはCPUが強いプランが来てくれたらかなと思った。
また↓の記事では不安定だったというのもあるのでそのあたりも改善されたい。
あとGitHub Actionが出たことによってCircleCIやBitriseとの競争がより激しくなり、互いのサービスがよりよくなる流れがきてるのがいいなと思った。
Kotlin Fest 2019に参加した #kotlinfest
2019/08/24(土)に行われたKotlin Fest 2019に参加しました。
去年
会場は去年と同じ東京コンファレンスセンター品川でした。
まい泉の軽食うまい。
トートバッグは黒にしました。
オープニング待機。
今年は英語セッションがあり、同時通訳レシーバーや案内が英語で行われたりと海外から参加者への対応が強化された印象でした。
お昼は去年に引き続き3Fの500円カレービュッフェ
いろんなお菓子がデプロイされてました。
以下セッションのメモ
基調講演
- KEEP(Kotlin Evolution and Enhancement Process)
- Inline classはIdクラスに使えそう
- ImmutableListはListに比べてなにがうれしい?
Read-onlyは自分で中身いじれない(が、他のところで可能になってる可能性)。immutable は初期されてからはいじるのは不可! #kotlinfest
— Panini@KotlinFest (@panini_ja) August 24, 2019
Listはinterfaceであり、実装ではない。MutableListと対等な実装が今存在していない。それとインスタンスを内部的にコピーなどする必要があるメソッドが存在しているが、immutableListという明示的にimmutableなものがあればそういう無駄な処理が無くなりパフォーマンスも上がるとのことでした。
— shiraji (@shiraj_i) August 24, 2019
- 新しい本を執筆中とのこと(Kotlinを書いたことないエンジニア向け?)
- Kotlin MPPはビジネスロジックの共有が目的でUI部分の共有はしない。本番環境での事例もちらほら出始めている
Kotlin コルーチンを 理解しよう 2019
- suspend関数の中でasyncしてエラーが起きたときにクラッシュしたときにハンドリングできない部分の説明が最高にわかりやすい(スライドの140ページ辺り)
- Progress -> suspend関数で中断 -> Completedをテストするときは、そのsuspend関数をモックしてdelayし、advanceTimeByして仮想的に時間をすすめることでPregress -> suspend関数で中断 -> Completedの状態の変化をテストできる
改めて学ぶContracts
- 1.3.50で26メソッドでContractsに対応された
- Kotlin ConfでKotlin 1.4の話があり、その中でContractsについても話がありそう
@Contract
というJava用のアノテーションをJetBrainsが提供している。そこから契約とはなにかを探る@Contarct
はKotlinに対応しておらず、改良してKotlinに入れる話がKEEPにあったが対応がしんどい、IDEが怒るだけで値が設定できない訳ではないから見送られた- AT_MOST_ONCEは0回か最大1回呼ばれることをコンパイラに伝える
- デフォルトはUNKNOWNで何回呼ばれるかわからない
- Inline classのほうがIssueは多いけど、Contractsも負けてない
- コンパイル時間などの問題がまだまだあるのですぐにstableにはならなそう
Kotlin Multiplatform Project入門
- Kotlin MPPはロジック部分の共通化のみでUIの共通化は行わない
- Jetpack compose/Swift UIでUI部分の共通化の可能性がでてきた
- Kotlin/JVM, Kotlin/Native, Kotin/JSなどを含めてKotlin MPP
- Kotlin/Nativeはバイナリを出すのであってiOS向けだけでなく、Mac, Linuxなどにも対応する
- 大きなメリットとしてはドメインオブジェクト・ロジックの共通化。Gradle pluginなので導入が簡単(build.gradleの設定は大変そう)
- デメリットとしてはJavaの資産(RxJavaなど)を使えない、iOSでSwiftから直接suspend関数が呼べずCoroutinesがメインスレッドしか呼ばれないため別途対応が必要である等
Kotlin/Nativeはなぜ動くのか?
おわりに
今回セッション募集があったので出してみたんですが残念ながらCfPが採用されず次回は発表できたらなと思いました。 今年はAndroidっていうよりKotlin MPPやWeb側の話が多い印象で、AndroidだけじゃなくWeb方面でもKotlinが広がってきたなと感じました。気になったのはKotlin MPPで、Gradle pluginで既存プロジェクトに対してもKotlin MPPが始められそうなので荒谷さんの資料やブログを見つつちょっと触っていこうかなと思います。基調講演で話していたInline classが楽しみです。
DangerでTextViewのandroid:maxLinesがないことをチェックしようとした
APIからデータを受け取ってTextViewに表示するとき、大きいデータだと際限なく表示されてしまうので android:maxLines
を設定されたい。結構忘れがちなのでDangerで自動チェックしようとした...けどやりたかったことを全部満たすのは大変そうだったので妥協した話。
やりたかったこと
- レイアウトファイルのdiffのTextView内に
android:maxLines
がなかったら警告する - 警告にはファイルと行数を指定してPR上から飛べるようにする(e.g.
warn("android:maxLinesがありません", file: "app/src/main/res/layout/content_main.xml", line: 10)
) - 既存のTextViewには警告を出さない
結論から言うと最後の 既存のTextViewには警告を出さない
を諦めた
どうチェックするか?
Dangerではgit.diffでdiffが取れるのでパースできる。もしくは
active_files = (git.modified_files + git.added_files).uniq layout_files = active_files.select { |file| file.include?("app/src/main/res/layout/") }
とすれば変更されたレイアウトファイル一覧が取れるので そのファイル全体を読み込んでパースすることもできる。
まずgit.diffをパースする場合、問題としては行数が取れない。diffと実際のファイルを比べて行数を取ってくるのはしんどい...妥協としてTextViewの始まりから終わりまでを表示させてみたが削除部分が誤判定されてしまい排除しなくてはいけなく、更にしんどい...
in_textview = false maxline_exists = false textview_line = [] git.diff.patch.lines.each do |diff_line| if /^.*<TextView$/ === diff_line in_textview = true textview_line.clear message("In TextView, #{diff_line}") end if in_textview textview_line.push(diff_line) if diff_line.include?("</TextView>") || diff_line.include?("/>") in_textview = false if !maxline_exists message("Please add android:maxLines\n```xml\n#{textview_line.join()}```") end elsif diff_line.include?("android:maxLines") maxline_exists = true end end end
レイアウトファイル全体をパースする場合、PRでの変更外も警告してしまうので鬱陶しい可能性がある。 android:maxLines
が必要ないラベルなどがあると常に警告されてしまう。とはいえ行数が取れるので表示自体は理想通りになる。
active_files = (git.modified_files + git.added_files).uniq layout_files = active_files.select { |file| file.include?("app/src/main/res/layout/") } in_textview = false maxline_exists = false textview_line_num = 0 layout_files.each do |filename| file = File.read(filename) lines = file.lines lines.each_with_index do |l, num| if l.include?("<TextView") in_textview = true textview_line_num = num + 1 end if in_textview if l.include?("</TextView>") || l.include?("/>") in_textview = false if !maxline_exists message("Please add android:maxLines", file: "#{filename}", line: textview_line_num) end elsif l.include?("android:maxLines") maxline_exists = true end end end end
結論
警告表示が理想的なレイアウトファイル全体をパースする方向で妥協した。不要な警告なら無視すればよいし、例えばstyleが適用されてたらスキップするとか改善のしようはありそう。
マルチモジュール時代のAdmob設定
広告を表示したいなと思いAdmobを調べていたんですが、せっかくならちゃんとDIしてマルチモジュールで利用できるようにしようと思いやってみました。この記事ではAdmobの設定方法などは省略します。 目標としてはappモジュール(分割済みなら各featureモジュール)からはAdmobを意識せず広告が読み込める(=広告ライブラリが変わっても影響がない)ことです。
環境
- Dagger 2.23.2
- firebase-ads 18.1.1
- googler-services 4.3.0
- appcomapt 1.0.2
- recyclerview 1.0.0
- Kotlin 1.3.41
- Android Studio 3.4.2
モジュール構成
今回は広告のインターフェースとして advertisement
モジュールとその実装として admob
モジュールを用意しました。feature_hogeモジュールからはadvertisementモジュールのみが見えており、DIでその実装であるadmobモジュールの中身が渡されるというフローです。appは全てを知っており、DIを解決します。
advertisementモジュール
advertisementモジュールはインターフェースです。そのためDaggerに依存しません。今回はRecyclerViewの中で使いたかったのでViewHolderを用意し、汎用的にFragmentもabstract classとして用意します。onBindViewHolder()内でAdViewHolder#bind()が呼ばれる想定です。
abstract class AdViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { abstract fun bind() } abstract class AdFragment : Fragment()
これらを動的に利用するために AdProvider
インターフェースを提供します。
interface AdProvider { fun init(context: Context) fun newViewHolderInstance(parent: ViewGroup): AdViewHolder fun newFragmentInstance(): AdFragment }
admobモジュール
admobモジュールはadvertisementモジュールで定義されたインターフェースを実装しDIで提供します。
class AdmobViewHolder( parent: ViewGroup, itemView: View = LayoutInflater.from(parent.context).inflate(R.layout.item_admob, parent, false) ) : AdViewHolder(itemView) { private val adView: AdView = itemView.findViewById(R.id.adView) override fun bind() { adView.loadAd(AdRequest.Builder().build()) } } class AdmobFragment : AdFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val rootView = inflater.inflate(R.layout.fragment_admob, container, false) val adView = rootView.findViewById<AdView>(R.id.adView) adView.loadAd(AdRequest.Builder().build()) return rootView } companion object { fun newInstance() = AdmobFragment() } } class AdmobProvider : AdProvider { override fun init(context: Context) { MobileAds.initialize(context, BuildConfig.AD_APP_ID) } override fun newViewHolderInstance(parent: ViewGroup): AdViewHolder = AdmobViewHolder(parent) override fun newFragmentInstance(): AdFragment = AdmobFragment.newInstance() }
Admobの設定でApp IDと各広告のUnit IDが必要になります。これらはGitHubに入れたくないため、Googleで用意されているテスト用のデータをプロジェクトの gradle.properties
で設定し、本番用のデータを ~/.gradle/gradle.properties
に設定することで上書きます。
ADMOB_ID=admob_id # Test ID ADMOB_UNIT_ID_MAIN=ca-app-pub-3940256099942544/6300978111 ADMOB_UNIT_ID_SUB=ca-app-pub-3940256099942544/6300978111
この設定をbuild.gradleで読み込み、AndroidManifet/BuildConfig/string resourceに埋め込みます。
android { compileSdkVersion 28 defaultConfig { minSdkVersion 21 targetSdkVersion 28 versionCode 1 versionName "1.0" manifestPlaceholders = [ ad_app_id: "$ADMOB_ID" ] buildConfigField "String", "AD_APP_ID", "\"$ADMOB_ID\"" resValue "string", "ad_unit_id_main", "$ADMOB_UNIT_ID_MAIN" resValue "string", "ad_unit_id_sub", "$ADMOB_UNIT_ID_SUB" ... }
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.phicdy.sample.admob"> <uses-permission android:name="android.permission.INTERNET" /> <application> <meta-data android:name="com.google.android.gms.ads.APPLICATION_ID" android:value="${ad_app_id}" /> </application> </manifest>
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content"> <com.google.android.gms.ads.AdView xmlns:ads="http://schemas.android.com/apk/res-auto" android:id="@+id/adView" android:layout_width="wrap_content" android:layout_height="wrap_content" ads:adSize="BANNER" ads:adUnitId="@string/ad_unit_id_main" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
次にDIの設定です。 AdmobProvider
を AdProvider
の実装として提供します。
@Singleton @Component( modules = [AdModule::class] ) interface AdComponent { fun adProvider(): AdProvider @Component.Factory interface Factory { fun create(): AdComponent } } @Module class AdModule { @Provides fun provideAdProvider(): AdProvider = AdmobProvider() }
appモジュール
appモジュールでは AdComponent
を利用して AdProvider
を提供します。これでfeature_hogeモジュールでは @Inject
するだけでAdProviderが使えるようになります。
@Singleton @Component( modules = [ AndroidInjectionModule::class, AppModule::class, ActivityModule::class, AdComponentModule::class ] ) interface AppComponent : AndroidInjector<SampleApplication> { @Component.Factory abstract class Factory : AndroidInjector.Factory<SampleApplication> } @Module object AdComponentModule { @JvmStatic @Provides @Singleton fun provideAdProvider( adComponent: AdComponent ): AdProvider { return adComponent.adProvider() } @JvmStatic @Provides @Singleton fun provideAdComponent() = DaggerAdComponent.factory().create() }
おわりに
書き終わって思いましたが、これはAdmobに限らずTrackerなど使う側が実装を知らずに利用したい多くのケースで流用できると思います。マルチモジュールにすることでfeatureモジュールがfirebase-adsなどに依存しなくて済むし、差分ビルドが早くなる恩恵も受けられます。
Chrome Custom TabでデフォルトブラウザがChrome以外の場合の対応
デフォルトブラウザがChrome以外のとき、FirefoxはChrome Custom Tabで開いてくれるがドルフィンブラウザなどは非対応のブラウザではそちらが開いてしまう。
Chrome Custom TabのIntentに com.android.chrome
を指定してあげればChrome Custom Tabで開けるようになる。万が一Chromeがないまたは無効化されている場合に備えて ActivityNotFoundException
をハンドリングしておく。
今回はSnackbarを指定しているが、もっと丁寧にやるならWebViewで開くようにしてあげるともっといいかもしれない。
try { val customTabsIntent = CustomTabsIntent.Builder() .setShowTitle(true) .setToolbarColor(ContextCompat.getColor(activity, R.color.background_toolbar)) .build() customTabsIntent.intent.setPackage("com.android.chrome") // Chromeを指定 customTabsIntent.launchUrl(context, Uri.parse(url)) } catch (e: ActivityNotFoundException) { // Chromeがない、または無効にされている Snackbar.make(findViewById(R.id.fab), R.string.open_internal_browser_error, Snackbar.LENGTH_SHORT).show() }
Androidのマルチモジュールでのjacoco設定
ハマったのでまとめておく。結論から言うとjacocoにアップロードしてGitHubのPR連携をする場合はレポートをマージする必要はない。マージしてしまうとパスを判別できなくなるせいかエラーになってしまう。
CircleCIで bash <(curl -s https://codecov.io/bash)
としていれば勝手に複数のレポートをアップロードしてくれる。
一方で自分でJenkinsなどを立てている場合はマージしてファイルを1つにしないとグラフ表示などの設定がうまくいかない。
マージする場合
build.gradle
buildscript { ... ext.jacoco_version = "0.8.2" ... } apply plugin: 'jacoco' jacoco { toolVersion = jacoco_version } task jacocoMerge(type: JacocoMerge) { gradle.afterProject { p, state -> if (p.rootProject != p && p.plugins.hasPlugin('jacoco')) { executionData file("${p.buildDir}/jacoco").listFiles().findAll { it.name.endsWith(".exec") } } } } def coverageExcludeFiles = ['**/R.class', '**/R$*.class', '**/com/android/**/*.*', '**/BuildConfig.class', '**/*Activity*.class', '**/*Fragment*.class', '**/*Receiver.class', '**/*Manifest*.class', '**/*Application*.class', ....] task jacocoMergedReport(type: JacocoReport, dependsOn: [tasks.jacocoMerge]) { executionData jacocoMerge.destinationFile def sources = [] subprojects.forEach { sources += "${it.projectDir.path}/src/main/java" } sourceDirectories = files(sources) classDirectories = fileTree( dir: ".", includes: ["**/build/intermediates/classes/debug/**", "**/build/tmp/kotlin-classes/debug/**"], excludes: coverageExcludeFiles ) reports { xml.enabled = true html.enabled true csv.enabled false xml.destination file("${buildDir}/reports/jacoco/report.xml") html.destination file("${buildDir}/reports/jacoco/html") } } task testDebugUnitTest(dependsOn: [tasks.jacocoMergedReport])
マージしない場合
phicdy.hatenablog.com phicdy.hatenablog.com
辺りの設定を各モジュールに設定すればOK。
具体的には↓みたいな設定を jacoco.gradle
としてプロジェクトのルートに置く。
apply plugin: 'jacoco' jacoco { toolVersion = jacoco_version } // A list of directories which should be included in coverage report def coverageSourceDirs = ['src/main/java', 'src/main/kotlin'] // A list of files which should be excluded from coverage report since they are generated and/or framework code def coverageExcludeFiles = ['**/R.class', '**/R$*.class', '**/com/android/**/*.*', '**/BuildConfig.class', '**/*Activity*.class', '**/*Fragment*.class', '**/*Receiver.class', '**/*Manifest*.class', '**/*Application*.class', ...] task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) { group = "Reporting" description = "Generate Jacoco coverage reports after running tests." reports { xml.enabled true html.enabled true csv.enabled false xml.destination new File("${buildDir}/reports/jacoco/jacocoTestReport.xml") html.destination new File("${buildDir}/reports/jacoco/html") classDirectories = fileTree(dir: "${buildDir}/intermediates/classes/debug", exclude: coverageExcludeFiles) + fileTree(dir: "$buildDir/tmp/kotlin-classes/debug", excludes: coverageExcludeFiles) } sourceDirectories = files(coverageSourceDirs) executionData = files "${buildDir}/jacoco/testDebugUnitTest.exec" doLast { println "jacoco xml report has been generated to file://${buildDir}/reports/jacoco/report.xml" println "jacoco html report has been generated to file://${reports.html.destination}/index.html" } }
そしてjacocoを使いたいモジュールで apply from: "$rootDir/jacoco.gradle"
するかもしくはルートのbuild.gradleで適用させてしまうと楽。
subprojects { if (name == 'app') { apply plugin: 'com.android.application' } else { apply plugin: 'com.android.library' } apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' apply from: "$rootDir/jacoco.gradle" ... }
参考
Koin 2.0へのマイグレーション
先日Koin 2.0が出たのでアップデートしてみました。
エントリポイントの変更
1.0まではApplicationクラスでstartKoin()に自身とモジュールのリストを渡していましたが、DSLになり、個別に指定するようになりました。
- startKoin(this, listOf(appModule)) + startKoin { + androidLogger() + androidContext(this@MyApplication) + modules(listOf(appModule)) + }
スコープの変更
1.0までは自分で名前をつけており、非常に不便でした。2.0からはActivity/Fragmentを型として指定できるので便利です。
- scope("main") { (view: MainActivityView) -> - MainActivityPresenter( - view = view, - hogeRepository = get(), - fugaUseCase = get() - ) + scope(named<MainActivity>()) { + scoped { (view: MainActivityView) -> + MainActivityPresenter( + view = view, + hogeRepository = get(), + fugaUseCase = get() + ) + } }
Activity/Fragment側では1.0までは bindScope(getOrCreateScope("main"))
と書いていましたが不要になりました。そしてinject()の代わりにcurrentScope.inject()を使います。これで自動的にスコープが紐づくようです。
- private val presenter: MainActivityPresenter by inject { parametersOf(this) } + private val presenter: MainActivityPresenter by currentScope.inject { parametersOf(this) }
私の環境ではこれだけでマイグレーションできました。もっといろんな機能を使っている方は公式サイトも2.0のドキュメントになっているのでそちらを参照すれば対応可能かと思います。
おわりに
Annotation Processorを使っているDaggerほどではないもののKoin 2.0ではかなり起動時のパフォーマンスが改善されました。今後にも期待です。