eaglesakuraの技術ブログ

技術的な話題とか、メモとか。

Androidアプリのmicro-module設計

  • サーバーサイドの世界では、micro-servicesとか流行ってるね
  • Androidの世界で、microな設計思想は取り入れられるのか?

Androidアプリのmoduleはどこまで分けられるのか?

  • Domain
  • Bridge
    • ドメインで定義したServiceの実装詳細を書く
  • Factory
    • どのブリッジを使用するか選択する
    • SQLite使う? Realm使う? そういう切り分けを行う
  • Components
  • Application
    • 最終的な成果物作成モジュール
    • google-services.jsonを組み込んだり、Crashlyticsを組み込んだり
    • Flavorでapkを分けたりする

Componentsをどのように分けるか

Activityをinternal化する

  • もちろん、関連するFragmentも全部internalにする
// こうしたい
// こうすることで、画面設計の詳細が[app_components_splash]モジュールから漏れ出さない
internal class SplashActivity : Activity() {
}
  • Kotlinコードからアクセスできないだけで、AndroidManifestに書けばちゃんと動作する

Activity間の遷移をどうするか?

  1. [app]
  2. [app_components_splash]
  3. [app_components_main]
// app/build.gradle
// [app_components_splash]と[app_components_main]を完全に非依存としたい
dependencies {
  implementation project(':app_components_splash')
  implementation project(':app_components_main')
}
  • 通常はこうしたいが
  • 無策にやると、これはできない
// SplashActivity.kt から、 MainActivity.kt は見えない!
// なぜなら、別moduleかつ非依存かつinternalで定義されているから!!
startActivity(Intent(context, MainActivity::class.java))

Component間に規約を設ける

  • app_components_main/AndroidManifest.xmlにmeta-dataを追加
  • こうすることで、Contextから拾うことができる
<?xml version="1.0" encoding="utf-8"?>
<manifest >
    <application android:theme="@style/Theme.AppCompat.NoActionBar">
        <activity
            android:name=".MainActivity">
            <meta-data
                android:name="com.eaglesakura.modules.COMPONENT_KEY"
                android:value="com.eaglesakura.modules.screen.MAIN" />
        </activity>
    </application>
</manifest>

Contextからmeta-data経由でIntentを生成する

// こんな感じでpackageInfo取得 -> activityInfo検索 -> Intent生成が行える

packageInfo = context.packageManager.getPackageInfo(
    context.packageName,
    PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_META_DATA
)

for (activityInfo in (packageInfo.activities ?: emptyArray())) {
    val componentKey = activityInfo.metaData?.getString("com.eaglesakura.modules.COMPONENT_KEY")
    if("com.eaglesakura.modules.screen.MAIN" == componentKey) {
      return Intent().also {
          it.component = ComponentName(context.packageName, activityInfo.name)
      }
    }
}

突き詰めるとどうなるか

  • 1画面1module、1service1module、1provider1module...というレベルまで細分化できる
  • [app] モジュールは、最終的なビルドフレーバーを分けたりapplication-idを確定させる程度の存在になる

利点はあるのか?

  • UIやシステムコンポーネントレベルでの依存性が無くなるため、moduleの交換が容易になる
    • 1画面だけ何か新しいライブラリを入れてみよう、といったことが行える
    • デザインの変更
      • 「この画面だけに必要なリソース」等を分けやすい
    • 特定moduleを捨てる決断が容易になる
  • module間が粗結合になるため、担当者を分けやすい
  • 実際に分けてみると、依存しているライブラリが「この画面のためだけに導入した」という感じになる
    • 意図せず使ってしまって密結合になるケースがある
    • そういったケースを防げる

欠点は何か?

  • moduleがめっちゃ増える
  • PCへの負荷が増える
    • Android Studio 3.2世代ならまあ問題ない
    • i7 7500U, RAM16GB + Ubuntu環境でそれなりに開発できる
    • 物理4コア以上でしょ、いまどき?
  • Gradleの implementation api , Kotlinの internal に対する知識が最低限必要になる
    • 何がinternalなのか、ではない
    • 何がpublicなのか に注目して設計し、それ以外は全部privateかinternalにする
  • メモリを食う
    • CircleCIのデフォルトメモリ(4GB)だと稀によく落ちる場合がある
  • res/*.xml が増える

CIのメモリ不足対処

  • どうしてもmodule数が増えるとメモリ使用量も増える
    • 予算や規模の都合でresource_classが変更できない場合
  • compileとassemble/testを分ける
# *.classを先行して生成する
./gradlew --no-daemon :app_components_main:compileDebugSources

# *.classのキャッシュを使うことで、メモリ使用量を抑える
./gradlew --no-daemon :app_components_main:testDebug

結論として、この設計は使い物になるか?

  • 今の所使えそう

AndroidManifestやApplicationIDに記述されるPackage名にsnake_caseはアリかナシか

Package名の命名規則を、メジャーな形に統一したい

  • プロジェクト内で統一したかった
    • package.name.hoge.fuga package.name.hoge_fuga package.name.hogeFuga あたりの競い
  • けど snake_case はLintで怒られる
  • allword は単語登録が毎度必要になる
  • cammelCase は個人的にあんまり使ったこと無い

有名そうな、snake_case

play.google.com

有名そうな、allword

play.google.com

まさかの CammelCase

play.google.com

あんまりしたくない ${PRODUCT_NUMBER}

play.google.com

結論

  • プロジェクト内で統一し、好きにすればいいんじゃない
  • allword あたりで落ち着けよう

AndroidでWrite Once Test Anywhereは成功しなかった

経緯

  • JetpackにRobolectricが統合されたことにより、 @RunWith(AndroidJUnit4::class)JUnit Runnerが統一された
  • うまいこと実行できれば、CIで検証しやすくなる(Instrumentation TestをCIでやるのは金か手間か実行時間が必要)

駄目だった箇所

  • こういうExtensionを作って試した

github.com

// build.gradle
android {
    sourceSets {
        androidTest.java.srcDirs += ["src/test/java"]
    }
}
@RunWith(AndroidJUnit4::class)
class HogeTest {

    @Test
    fun fugaTestCase() = compatibleTest {
        // 両方で実行
    }

    @Test
    fun barTestCase() = instrumentationTest {
        // 実機で実行
    }
}
  • 結論から言えば、うまくいかなかった
  • 実行コード内でCoroutineがUI Dispatcherで withTimeout() していると、すぐさまタイムアウト扱いになる
    • コードを追っかけたら、Kotlin Coroutines内部でpostDelayedしているが、Robolectricは瞬時に実行するので待ち合わせがうまくいかない
  • 丁寧に InstrumentationとLocalの両実行 Instrumentationだけで実行 をうまく選択すればまあなんとか、という感じ

Android Studioの対応が不完全だった

  • androidTestのsourceSetsに加えられたソースコードは、実行時に必ずInstrumentationTestが対象となる
    • Run用のConfigurationを自分で書けばもちろん実行できるけど
    • 毎度それを書くのは面倒

今後どうするか?

  • とりあえず実行可能箇所が増えるのは嬉しいので、しばらく両実行のコードを増やすようにしてみる

AndroidでKotlin1.3-RCへの移行を試みる

早期移行する理由

  • coroutinesのstable版へ移行したい
  • 移行時の問題点を洗い出しておきたい

対応箇所

/build.gradle

buildscript {
    // 本体とcoroutinesのそれぞれのバージョン
    // coroutinesは標準機能に昇格したが、Android(及びJVM)用ライブラリは別途配布されている
    ext.kotlin_version = '1.3.0-rc-146'
    ext.kotlin_coroutines_version = '0.30.2-eap13'
    ext.army_knife_version = '0.6'
    repositories {
        google()
        jcenter()
        mavenCentral()
        // eap版リポジトリを追加する
        maven { url "https://dl.bintray.com/kotlin/kotlin-eap" }
    }
}

/module/build.gradle

kotlin {
    experimental {
        // coroutinesはexperimentalじゃなくなった!
//        coroutines "enable"
    }
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
}

ソースコード

// coroutinesはexperimentalじゃなくなった!!
// import kotlinx.coroutines.experimental.*
import kotlinx.coroutines.*

移行の感触

  • package名の移行だけでひとまずビルドは通る

いつか書きたいMVVM/BFアーキテクチャの話

MVVM/BFによるAndroidアプリのClearn ArchitectureとDomain Driven Design導入

  • 今年一番スッキリした設計パターン
  • いつかアウトプットしたい
  • いつだろうか?

Toc

MVVM/BF解説

  1. 各層の役割
  2. Model
    1. ドメインサブドメイン、Serviceなど、プラットフォーム非依存コード
  3. ViewModel
    1. JetpackのViewModelやDataBinding
  4. View
    1. Androidのシステムコンポーネント(Activity, Service, ContentProvider...)
  5. Bridge
    1. Modelを実装した、実装・プラットフォーム依存層
    2. 通信をOkHttpに依存したり、Firebase SDKに依存したりする
  6. Factory
    1. BridgeとViewModelの中間を担う
    2. 実体(インスタンス)の正体を知っている唯一の存在
    3. DIライブラリを導入する唯一の層

Model層の役割

  1. DDDを読もう
  2. 命名の気になるところ
  3. Service? それともService?
    1. AndroidのServiceと標準的な名付け(サフィックス)が競合する
    2. 気にしない

Bridge-Factoryの役割

  1. 各層の依存関係
  2. 実装はBridge層が行う
  3. インターフェースの振り分けはFactory層が行う
  4. Bridge層の公開クラスと非公開クラス

View/ViewModel層の役割

  1. ActivityとFragmentの役割
  2. FragmentからActivityへメッセージを送る
  3. FragmentMainとCallbackパターン
  4. ViewModelを使うパターンと使わないパターン
  5. Eventパターン
  6. FragmentはいつstartActivityを呼ぶか?
  7. 論理画面内であれば呼ぶ
  8. 例えばGoogle認証は同じ論理画面なのでFragmentから呼ぶ

DIライブラリに依存しないDI

  1. Kotlin言語機能のみでの依存注入
  2. Factory層で完結する依存注入
  3. DIライブラリの詳細をViewModel層へ伝えない

プロジェクト構成例

  1. 前提条件
  2. 強力なPCを使うことが、実現への第一歩
  3. 設計力矯正ギブスをつける
  4. domain/bridge/factory/appで分ける
  5. build.graldeの implementationapi を使い分ける
  6. Kotlinの internal 仕様を活用する
  7. 小分けしたModuleの初期化例

共通UIウィジェットの扱い方

  1. 共有UIはBridge/Factoryに依存しない
  2. 依存しないのに、誰がModelとView/ViewModelを結ぶのか
  3. Callbackパターンを使う

QRコード読み取りの例

  1. Model層での設計
  2. Bridge層での実装
  3. Zxing実装版
  4. Google Play Mobile Vision実装版
  5. Factory層での振り分け

結合テスト

  1. Bridge層での単体テスト
  2. Factory層での結合テスト
  3. View/ViewModel層での結合テスト

CircleCIの無料プランでメモリが足りずにgradleタスクが失敗する場合の対処

タスクを分割する

  • 重いタスクがあったら、細分化してやる
# 変更前(全タスクが一度に実行)
./gradlew test
# 変更後(個別に実行)
./gradlew :app_domain:test
./gradlew :app:test

config.ymlを見直す

  • メモリ量は4GB以内で調整
        environment:
          _JAVA_OPTIONS: -Dfile.encoding=UTF-8
          GRADLE_OPTS: "-Xmx3g"
          TZ: Asia/Tokyo

KILL THEM ALL

# タスク実行前にdaemonをKILL
pkill -KILL -f java
./gradlew :app_domain:test
# また実行前にdaemonをKILL
pkill -KILL -f java
./gradlew :app:test

daemonなしでは生きられない

  • --no-daemon オプションを指定すると、gradle-kotlinビルドプラグインがクラッシュする

daemonは醜く太る

  • daemonを起動しっぱなしにすると、ビルドのたびにメモリが圧迫される
  • Android Studioでも同様の現象があるので、開発中にOOMが出たら再起動してみよう。多分治る。

太ったdaemon KILL THEM ALL

  • pkill -KILL -f java ですべて粛清する
  • また1から世界を作ろう

メモリが足りないなら

Kotlinの拡張関数とUtilクラスの使い分け ver 2018.09

最初にやるべきこと

  • Jetpack-KTXを探す
  • 公式拡張関数を探す

拡張関数(プロパティ)を使う場合

  • 単体で存続できるケースは拡張関数を許可する
  • 仮にこの拡張関数が属するライブラリと離婚することになっても、この処理は容易に移行できる
// DO
val Context.debugMode: Boolean
    get() = packageManager.getApplicationInfo(packageName, 0)?.let { appInfo ->
        return appInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == ApplicationInfo.FLAG_DEBUGGABLE
    } ?: false

if(context.debugMode) {
  ...
}

Utilクラスにまとめる場合

  • 自身のライブラリや、他のライブラリへの依存がある場合はUtilクラスにまとめる
// DO NOT
fun Context.getMyInfo(): MyInfo {
  return MyInfo(this)
}

val myInfo = context.getMyInfo()
// DO
val myInfo = HogeUtil.getMyInfo(context)

拡張関数(プロパティ)とクラスの使い分けに迷ったら

  • 文脈として自然であると考えられるなら、拡張関数を許可する
    • 拡張関数のほうがIntellisenseとの親和性が高い

同じ名前の拡張関数を見つけたら?

  • 公式(もしくはメンテナンス頻度が高いもの)を優先する