<エムスリー Advent Calendar 2020 まで残り6日となりました。Advent Calendar本編に先んじて新卒1〜2年目メンバーが執筆します。>
エムスリーエンジニアリンググループ マルチデバイスチーム 新卒1年目の小林(@kobasato34)です。 入社してからは、AndroidネイティブアプリやFlutter製のアプリを開発しています。
ネイティブアプリの方では一部の実装にKotlin Multiplatform Mobile(以後KMM)が導入されていたり*1、またFlutterを採用したプロジェクトも増えていたりと、今マルチプラットフォームなアプリ開発が私の周りで流行っています。
ですがKMM部分の実装を自分でしたことはまだ無かったので、Brainf*ckエディタアプリ作りを通してロジックの共通化を体験してみました。
この記事では、KMMを用いたAndroid/iOSアプリのロジックの共通化とSwiftからの共通ロジックの呼び出し方法について説明します。
Kotlin Multiplatform Mobile (KMM)について
Kotlinで書かれたコードをiOS/Androidアプリで使用できるようにするSDKです。
UIは各プラットフォーム側で実装し、ロジック部分はKotlinで共通化できます。
今年の9月にアルファ版になりました!
Brainf*ckエディタアプリ
Brainf*ckはチューリング完全なプログラミング言語の1つです。8つの命令しかなく実装が簡単であるため今回選びました。
実装する機能は、
- プログラム作成・実行できる
- プログラムを保存・リスト表示できる
とします。
設計
設計はこんな感じにしてみました。 Brainf*ckのインタプリタはもちろんですが、データの保存・取得部分もKMM側で実装することで、Android/iOS側の実装量を減らすことができます。
プロジェクト作成
Android Studioでプロジェクト作成を行います。
プラグインを導入するとKMMプロジェクトを作成できるようになります。
デフォルト設定で作成したプロジェクトのディレクトリ構成はこんな感じです。
. ├── androidApp | ├── build.gradle.kts │ ├── ... ├── build.gradle.kts ├── gradle │ └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── iosApp │ ├── ... ├── local.properties ├── settings.gradle.kts └── shared ├── build.gradle.kts └── src ├── androidMain │ ├── AndroidManifest.xml │ └── kotlin │ └── com │ └── kobasato │ └── kmmbrainfuck │ └── shared │ └── Platform.kt ├── androidTest │ └── kotlin ├── commonMain │ └── kotlin │ └── com │ └── kobasato │ └── kmmbrainfuck │ └── shared │ ├── Greeting.kt │ └── Platform.kt ├── commonTest │ └── kotlin ├── iosMain │ └── kotlin │ └── com │ └── kobasato │ └── kmmbrainfuck │ └── shared │ └── Platform.kt └── iosTest └── kotlin
androidApp
Androidアプリのプロジェクトです。
iosApp
iOSアプリのXcodeプロジェクトです。
shared
共通コードを記述していくモジュールです。commonMain
にプラットフォームに依存しないコード、iosMain
やandroidMain
にプラットフォーム固有のコードを書きます。
インタプリタの実装
インタプリタはプラットフォームに依存しないロジックなので、shared/src/commonMain/~
に記述していきます。
実装はこんな感じにしました。 バグってたらすみません。
,
は入力を受け付ける命令ですが、アプリの実装を簡単にするために未実装です。
// shared/src/commonMain/kotlin/com/kobasato/kmmbrainfuck/shared/Interpreter.kt class Interpreter { companion object { fun execute(input: String): Output { val state = State() var outputString = "" var index = 0 try { while (index < input.length) { when (input[index]) { '>' -> state.incrementPointer() '<' -> state.decrementPointer() '+' -> state.incrementValue() '-' -> state.decrementValue() '.' -> outputString += state.value.toChar() ',' -> throw Exception("\",\" is not implemented") '[' -> { if (state.value == 0.toByte()) { var leftBracketCount = 1 while (index < input.length - 1 && leftBracketCount > 0) { index++ when { input[index] == '[' -> leftBracketCount++ input[index] == ']' -> leftBracketCount-- } } } } ']' -> { if (state.value != 0.toByte()) { var rightBracketCount = 1 while (index > 0 && rightBracketCount > 0) { index-- when { input[index] == '[' -> rightBracketCount-- input[index] == ']' -> rightBracketCount++ } } } } } index++ } } catch (e: Throwable) { return Output.Error(e) } return Output.Success(outputString) } } } class State { private val memory: MutableList<Byte> = mutableListOf(0) private var pointer = 0 var value: Byte get() = memory[pointer] set(value) { memory[pointer] = value } fun incrementPointer() { pointer++ if (pointer >= memory.count()) { memory.add(0) } } fun decrementPointer() { pointer-- if (pointer < 0) { throw Exception("Pointer out of range") } } fun incrementValue() { memory[pointer]++ } fun decrementValue() { memory[pointer]-- } } sealed class Output { data class Success(val outputString: String) : Output() data class Error(val cause: Throwable) : Output() }
テストも軽く書いておきます。
// shared/src/commonTest/kotlin/com/kobasato/kmmbrainfuck/shared/brainfuck/Interpreter.kt class InterpreterTest { @Test fun helloWorld() { val input = ">+++++++++[<++++++++>-]<.>+++++++[<++++>-]<+.+++++++..+++.[-]>++++++++[<++++>-]<.>+++++++++++[<+++++>-]<.>++++++++[<+++>-]<.+++.------.--------.[-]>++++++++[<++++>-]<+." val output = Interpreter.execute(input) val expected = Output.Success("Hello World!") assertEquals(expected, output) } @Test fun plus() { val input = "+++>+++><<[->[->>+<<]>>[-<+<+>>]<<<]>>++++++++++++++++++++++++++++++++++++++++++++++++." val output = Interpreter.execute(input) val expected = Output.Success("9") assertEquals(expected, output) } @Test fun outOfRange() { val input = "<" val output = Interpreter.execute(input) assertTrue { output is Output.Error } } }
データ保存部分の実装
先に、書いたプログラムをDBに保存する機能を実装します。
DBの処理には、KMMに対応しているSQLDelightというライブラリを使います。
kotlinx.coroutinesの追加
iOSでマルチスレッドで動作できるようにするため、マルチスレッド対応バージョンのものを使います。
// shared/build.gradle.kts sourceSets { val commonMain by getting { dependencies { // ... implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1-native-mt") } } // ... }
SQLDelightの追加
SQLDelightはSQLからKotlinコードの生成を行うため、まずはGradleプラグインを追加します。
gradle.properties
にバージョンを定義しておきます。
sqlDelightVersion=1.4.4
// build.gradle.kts (project root) buildscript { val sqlDelightVersion: String by project // ... dependencies { // ... classpath("com.squareup.sqldelight:gradle-plugin:$sqlDelightVersion") } }
// shared/build.gradle.kts // ... val sqlDelightVersion: String by project sourceSets { val commonMain by getting { dependencies { // ... implementation("com.squareup.sqldelight:runtime:$sqlDelightVersion") } } // ... val androidMain by getting { dependencies { // ... implementation("com.squareup.sqldelight:android-driver:$sqlDelightVersion") } } // ... val iosMain by getting { dependencies { // ... implementation("com.squareup.sqldelight:native-driver:$sqlDelightVersion") } } // ... }
SQLDelightの設定
パッケージ名やDBの名前(ここでは”AppDatabase”)を設定します。
// shared/build.gradle.kts // ... sqldelight { database("AppDatabase") { packageName = "com.kobasato.kmmbrainfuck.shared.db" } }
SQLを書く
上で設定した場所に.sq
ファイルを作成します。.sq
ファイルを元にKotlinコードが生成されます。
-- shared/src/commonMain/sqldelight/com/kobasato/kmmbrainfuck/db/Program.sq CREATE TABLE Program ( id TEXT PRIMARY KEY, title TEXT NOT NULL, input TEXT NOT NULL ); insertOrUpdate: INSERT OR REPLACE INTO Program(id, title, input) VALUES(?, ?, ?); selectAll: SELECT * FROM Program ORDER BY title;
buildすると、このようなdata classとクエリが生成されます。
data class Program( val id: String, val title: String, val input: String ) { override fun toString(): String = """ |Program [ | id: $id | title: $title | input: $input |] """.trimMargin() } interface ProgramQueries : Transacter { fun <T : Any> selectAll(mapper: ( id: String, title: String, input: String ) -> T): Query<T> fun selectAll(): Query<Program> fun insertOrUpdate( id: String, title: String, input: String ) }
Repositoryを実装
まずはSqlDriverのfactoryクラスを作成します。実装は各プラットフォームで行うので、commonMainモジュールではexpect
キーワードをつけて宣言します。
各プラットフォームの実装は、iosMain
やandroidMain
モジュールでactual
キーワードを付けて行います。
// shared/src/commonMain/kotlin/com/kobasato/kmmbrainfuck/shared/DatabaseDriverFactory.kt internal const val dbName = "app.db" expect class DatabaseDriverFactory { fun createDriver(): SqlDriver } // shared/src/iosMain/kotlin/com/kobasato/kmmbrainfuck/shared/DatabaseDriverFactory.kt actual class DatabaseDriverFactory { actual fun createDriver(): SqlDriver { return NativeSqliteDriver(AppDatabase.Schema, dbName) } } // shared/src/androidMain/kotlin/com/kobasato/kmmbrainfuck/shared/DatabaseDriverFactory.kt actual class DatabaseDriverFactory(private val context: Context) { actual fun createDriver(): SqlDriver { return AndroidSqliteDriver(AppDatabase.Schema, context, dbName) } }
これで、AppDatabaseインスタンスを生成できるようになりました。
// 例 // Android val databaseDriverFactory = DatabaseDriverFactory(context) // iOS val databaseDriverFactory = DatabaseDriverFactory() val database = AppDatabase(databaseDriverFactory.createDriver())
ではRepositoryの実装に入ります。
getAll()
をFlow型で返すことで、変更の通知をできるようにします。
// shared/src/commonMain/kotlin/com/kobasato/kmmbrainfuck/shared/ProgramRepository.kt interface ProgramRepository { suspend fun addOrUpdate(program: Program) fun getAll(): Flow<List<Program>> } // shared/src/commonMain/kotlin/com/kobasato/kmmbrainfuck/shared/ProgramRepositoryImpl.kt class ProgramRepositoryImpl(database: Database) : ProgramRepository { private val queries: ProgramQueries = database.appDatabase.programQueries override suspend fun addOrUpdate(program: Program) = withContext(Dispatchers.Default) { queries.insertOrUpdate(id = program.id, title = program.title, input = program.input) } override fun getAll(): Flow<List<Program>> { return queries .selectAll() .asFlow() .mapToList(Dispatchers.Default) } }
自動生成されたProgramクラスのデータを自分で用意したmodelに詰め替えたりすると依存関係が綺麗になると思いますが、ここでは省略して生成されたProgramクラスをそのまま使用しています。
Serviceを実装
ここにユースケースを書いていきます。
suspend関数やFlowはSwiftからの呼び出しもサポートされています。
ですがFlowに関しては使い勝手がそんなに良くない気がしたので、iOS用にwrapしたCommonFlow
を用意しています。
// shared/src/commonMain/kotlin/com/kobasato/kmmbrainfuck/shared/FlowUtils.kt interface Closeable { fun close() } fun <T> Flow<T>.wrap(dispatcher: CoroutineDispatcher = Dispatchers.Main): CommonFlow<T> = CommonFlow(this, dispatcher) class CommonFlow<T>(private val origin: Flow<T>, private val dispatcher: CoroutineDispatcher) : Flow<T> by origin { fun watch(block: (T) -> Unit): Closeable { val job = Job() onEach { block(it) }.launchIn(CoroutineScope(dispatcher + job)) return object : Closeable { override fun close() { job.cancel() } } } }
CommonFlowを使ったServiceの実装はこちらです。
// shared/src/commonMain/kotlin/com/kobasato/kmmbrainfuck/shared/ProgramService.kt class ProgramService(private val programRepository: ProgramRepository) { suspend fun saveProgram(title: String, input: String): Program { val program = Program(id = generateUUID(), title = title, input = input) programRepository.addOrUpdate(program) return program } suspend fun updateProgram(program: Program, newTitle: String, newInput: String): Program { val updatedProgram = program.copy(title = newTitle, input = newInput) programRepository.addOrUpdate(updatedProgram) return program } fun getPrograms(): CommonFlow<List<Program>> { return programRepository .getAll() .wrap() } }
CommonFlowはFlowを継承しているので、Android側ではそのままFlowとして扱えます。
iOS側ではwatch
メソッドを使って値をコールバックで受け取ります。
また、id生成にはUUIDを用います。
しかし、UUIDを生成する関数は標準ライブラリにはないので、expect / actual
でそれぞれのプラットフォームで実装しています。
// shared/src/commonMain/kotlin/com/kobasato/kmmbrainfuck/shared/Uuid.kt expect fun generateUUID(): String
iOS
// shared/src/iosMain/kotlin/com/kobasato/kmmbrainfuck/shared/Uuid.kt import platform.Foundation.NSUUID actual fun generateUUID(): String = NSUUID().UUIDString()
Android
// shared/src/androidMain/kotlin/com/kobasato/kmmbrainfuck/shared/Uuid.kt import java.util.UUID actual fun generateUUID(): String = UUID.randomUUID().toString()
Dependency Injection
Kodeinというマルチプラットフォームに対応したDIライブラリを使用します。
// shared/build.gradle.kts // ... sourceSets { val commonMain by getting { dependencies { // ... implementation("org.kodein.di:kodein-di:7.1.0") } } // ... } // ...
// androidApp/build.gradle.kts // ... dependencies { // ... implementation("org.kodein.di:kodein-di-framework-android-x:7.1.0") } // ...
まずは共通のDIモジュールを記述します。
// shared/src/commonMain/kotlin/com/kobasato/kmmbrainfuck/shared/DiModule.kt val sharedModule = DI.Module("shared") { bind<SqlDriver>() with provider { instance<DatabaseDriverFactory>().createDriver() } bind<AppDatabase>() with singleton { AppDatabase(instance()) } bind<ProgramRepository>() with provider { ProgramRepositoryImpl(instance()) } bind<ProgramService>() with provider { ProgramService(instance()) } }
with singleton { ... }
と書くことでsingletonにすることが可能です。
AndroidではApplicationでDIAwaweインタフェースを実装します。
また、DatabaseDriverFactory
の設定を追加で行います。
// androidApp/src/main/java/com/kobasato/kmmbrainfuck/androidApp/MyApplication.kt class MyApplication : Application(), DIAware { override val di by DI.lazy { import(sharedModule) bind<DatabaseDriverFactory>() with provider { DatabaseDriverFactory(this@MyApplication) } } }
ActivityやFragmentでこのようにインスタンスを取得できます。
// androidApp/src/main/java/com/kobasato/kmmbrainfuck/androidApp/MainActivity.kt class MainActivity : AppCompatActivity(), DIAware { override val di: DI by di() private val programService: ProgramService by instance() // ... }
iOSはまだ完全にサポートされていないので、wrapperを作成してそれを使うようにします。
// shared/src/commonMain/kotlin/com/kobasato/kmmbrainfuck/shared/Injector.kt class Injector { private val container = DI.lazy { import(sharedModule) import(iosModule) } private val iosModule = DI.Module("iosModule") { bind<DatabaseDriverFactory>() with provider { DatabaseDriverFactory() } } fun programService(): ProgramService = container.direct.instance() }
これで共通部分の実装は完了しました。後は、それぞれのプラットフォームでView部分を実装していきます。
Viewの実装
KMMではViewの共通化を行っていないので、それぞれのプラットフォームで自由に実装することができます。 今回のサンプルアプリでは、勉強を兼ねてJetpack ComposeとSwiftUIを使ってみました。
全部細かく紹介していくと記事のボリュームが大変なことになってしまうので、Jetpack ComposeとSwiftUIの説明は省略させていただきます。
また、Androidからはsharedモジュールは普通のモジュールと同じように扱うことができるので、ここではSwiftから共通コードを呼び出す部分をピックアップして紹介します。
companion objectのメソッド呼び出し
HogeClass.Companion().hogeMethod()
で呼び出すことができます。
func runProgram() { let result = Interpreter.Companion().execute(input: input) // ...
sealed class
if let ...
で値を取り出します。
if let result = result as? Output.Success { output = .success(output: result.outputString) } else if let error = result as? Output.Error { output = .error(message: error.cause.message ?? "") }
suspend関数の呼び出し
suspend関数は、Swiftからはcallbackとして扱うことができます。
programService.updateProgram(program: program, newTitle: title, newInput: input) { [weak self] result, error in self?.program = result }
CommonFlow
watch
メソッドのcallbackで処理します。
CommonFlowでgenericsを使用しているため、callbackで来る値の型がNSArray?
になるのでキャストしてあげます。
let closeable = programService.getPrograms().watch { [weak self] result in if let programList = result as? [Program] { self?.programList = programList } }
完成したアプリ
GitHubで公開しています。
https://github.com/kobasato34/KMMBrainfuck
Platform | List | Editor |
---|---|---|
Android | ||
iOS |
まとめ
Kotlin Multiplatform Mobileを使ってBrainf*ckエディタアプリを作成しました。
このアプリだと、インタプリタをそれぞれのプラットフォームで実装するのは確実に無駄なので、それを共通化できるのはかなり便利だと思います。
また、API呼び出しやDB操作も基本的には各プラットフォームで同じになると思うので、うまく共通化できるとアプリ開発効率の向上が期待できそうです。
とはいえ、まだアルファ版なので導入は慎重に検討しましょう。
We are hiring!
エムスリーではFlutterやKMM等の技術を適材適所で導入しています。 私は入社後ネイティブアプリから始まり、この半年間でFlutterのキャッチアップも行ってきました。 モバイルアプリ以外でも非常に幅広く技術を扱っています。 モバイルアプリを開発したい、様々な技術に触れたい、成長したいエンジニア(もちろん新卒も!)を募集しています!