phicdy devlog

Androidアプリ開発やその他技術系の記事をたまに書きます

AndroidとかiOSとかモバイル多め。その他技術的なことも書いていきます。

GitHub ActionでAndroidのCIを設定してみた

ベータを申し込んだら1日で有効になったので使ってみた

help.github.com

設定

ひとまず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を設定してあげればよい。

help.github.com

感想

まず無料でベータだから仕方ないかもしれないが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が強いプランが来てくれたらかなと思った。

また↓の記事では不安定だったというのもあるのでそのあたりも改善されたい。

diary.app.ssig33.com

あとGitHub Actionが出たことによってCircleCIやBitriseとの競争がより激しくなり、互いのサービスがよりよくなる流れがきてるのがいいなと思った。

Kotlin Fest 2019に参加した #kotlinfest

2019/08/24(土)に行われたKotlin Fest 2019に参加しました。

kotlin.connpass.com

去年

phicdy.hatenablog.com

会場は去年と同じ東京コンファレンスセンター品川でした。

f:id:phicdy:20190824105158j:plain f:id:phicdy:20190824105108j:plain

まい泉の軽食うまい。

f:id:phicdy:20190824105120j:plain

トートバッグは黒にしました。

f:id:phicdy:20190824105131j:plain

オープニング待機。

f:id:phicdy:20190824105142j:plain

今年は英語セッションがあり、同時通訳レシーバーや案内が英語で行われたりと海外から参加者への対応が強化された印象でした。

f:id:phicdy:20190824105902j:plain

お昼は去年に引き続き3Fの500円カレービュッフェ

f:id:phicdy:20190824182329j:plain

いろんなお菓子がデプロイされてました。

f:id:phicdy:20190824182305j:plain f:id:phicdy:20190824182315j:plain

以下セッションのメモ

基調講演

  • KEEP(Kotlin Evolution and Enhancement Process)

github.com

  • Inline classはIdクラスに使えそう
  • ImmutableListはListに比べてなにがうれしい?

  • 新しい本を執筆中とのこと(Kotlinを書いたことないエンジニア向け?)
  • Kotlin MPPはビジネスロジックの共有が目的でUI部分の共有はしない。本番環境での事例もちらほら出始めている

Kotlin コルーチンを 理解しよう 2019

speakerdeck.com

  • suspend関数の中でasyncしてエラーが起きたときにクラッシュしたときにハンドリングできない部分の説明が最高にわかりやすい(スライドの140ページ辺り)
  • Progress -> suspend関数で中断 -> Completedをテストするときは、そのsuspend関数をモックしてdelayし、advanceTimeByして仮想的に時間をすすめることでPregress -> suspend関数で中断 -> Completedの状態の変化をテストできる

改めて学ぶContracts

speakerdeck.com

  • 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入門

speakerdeck.com

  • 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はなぜ動くのか?

speakerdeck.com

  • JREで提供されているものをC++で実装している
  • GCを独自実装
  • LLVM経由で変換している

おわりに

今回セッション募集があったので出してみたんですが残念ながら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

f:id:phicdy:20190817220449p:plain

レイアウトファイル全体をパースする場合、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

f:id:phicdy:20190817220626p:plain

結論

警告表示が理想的なレイアウトファイル全体をパースする方向で妥協した。不要な警告なら無視すればよいし、例えば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を解決します。

f:id:phicdy:20190731225247p:plain

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の設定です。 AdmobProviderAdProvider の実装として提供します。

@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以外のとき、FirefoxChrome Custom Tabで開いてくれるがドルフィンブラウザなどは非対応のブラウザではそちらが開いてしまう。

f:id:phicdy:20190714185147g:plain

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連携をする場合はレポートをマージする必要はない。マージしてしまうとパスを判別できなくなるせいかエラーになってしまう。

f:id:phicdy:20190624224848p:plain

CircleCIで bash <(curl -s https://codecov.io/bash) としていれば勝手に複数のレポートをアップロードしてくれる。

f:id:phicdy:20190624225114p:plain

一方で自分で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"
    ...
}

参考

vividcode.hatenablog.com

clash-m45.hatenablog.com

github.com

Koin 2.0へのマイグレーション

先日Koin 2.0が出たのでアップデートしてみました。

medium.com

APIが変わっているのでマイグレーションが必要です。

エントリポイントの変更

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ではかなり起動時のパフォーマンスが改善されました。今後にも期待です。

参考

insert-koin.io