20190323のKotlinに関する記事は5件です。

【Kotlin】take や drop などを使わない場合でも Sequence で高速化【計測】

1行で

filtermap などの変換関数をチェインする場合は、takedrop などで要素数を減らさない場合であっても、Sequence を使うことで高速化できます。

詳しく

filtermap などの、List から別の List への変換関数。
これらをチェインして、変換処理を行うことはよくあるでしょう。

list
    .filter { it % 2 == 0 }
    .map { it.toString() }

それらの変換の後で takedrop などを用いて要素数を減らすような場合は、Sequence を使うことで、最終的に不要な要素に対しては filtermap などでの変換処理が適用されなくなり、大幅に効率を上げることができます。
これは Sequence についてある程度ご存知の方にはよく知られていることでしょう。

list
    .asSequence() // Sequence に変換
    .filter { it % 2 == 0 }
    .map {
        it.toString() // 5回までしか呼ばれない
    }
    .take(5)

しかし、takedrop などで要素数を減らさない場合も、Sequence を使うことで高速化が図れます。

次のコードは Sequence の方が高速であることを確認するコードの例です。
filtermap による変換を ListSequence それぞれに対して行い、それぞれに掛かる時間を計測します。
ガベッジコレクションのタイミングによるものか、値が大きく異なることがよくあるため、複数回の計測を行って中央の1/3だけを平均します。

fun main() {
    val list = List(1000) { Any() } // 変換元のリスト

    val repeatCount = 1000
    val listTime = TimeMeasurer(repeatCount)
    val sequenceTime = TimeMeasurer(repeatCount)

    // 誤差を減らすために繰り返し交互に行う。
    repeat(1000) {
        // List のままでの変換処理の所要時間を計測する。
        listTime.measure {
            list
                .filter { true }
                .map { it }
        }

        // Sequence に変換してからの変換処理の所要時間を計測する。
        sequenceTime.measure {
            list
                .asSequence()
                .filter { true }
                .map { it }
                .toList()
        }
    }

    println("list: ${listTime.average()}")
    println("seqence: ${sequenceTime.average()}")
}

/**
 * 処理の所要時間を計測するためのクラス。
 *
 * @param repeatCount 1回の計測での繰り返し回数。
 */
class TimeMeasurer(
    private val repeatCount: Int
) {
    private val times = mutableListOf<Long>()

    fun measure(block: () -> Unit) {
        times += measureTimeMillis {
            repeat(repeatCount) {
                block()
            }
        }
    }

    fun average(): Double {
        val size = times.size
        return times
            .sorted().drop(size / 3).take(size / 3) // 上位と下位それぞれの1/3を除く
            .average() / repeatCount
    }
}

結果の標準出力の例:

list: 0.015
seqence: 0.011603603603603603

Sequence の方が高速であることを確認できました。

List から List への変換では、関数呼び出しごとに List インスタンスが生成されます。
そしてそれらのインスタンスごとに、要素数分のメモリが確保されます。(そしてすぐ不要になります。)
Sequence ではこのメモリが不要なため、高速であることが期待されます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AndroidでRoom+RxJavaを実装する

本記事ではAACのRoomPersistenceLibraryとRxJavaを組み合わせた、基本的な実装のお話をしていこうと思います。
記事内のコードはKotlinで記述していますが、Coroutineなどは使っていないため、基本的にJavaに置き換えることも可能です。

対象読者

  • Room初学者
  • Room + RxJavaに興味がある方

セットアップ

最初にbuild.gradle(app)に以下を記述します。

def room_version='2.1.0-alpha06'
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-rxjava2:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
kapt "androidx.room:room-compiler:$room_version"

implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'
  • androidx.room:room-rxjava2を記述することによって、DAOの戻り値でSingleやMaybeなどを指定することができます。
  • room_versionに関しては2.1.0以上を使ってください。理由は後ほど詳しく記述しますが、2.1.0からInsertやdeleteの戻り値にCompletableを指定できるようになったからです。
  • AndroidXに対応していないと、2.0.0以上のバージョンを使うことができないため、1.1.1を使ってください。詳しくは こちら のサイトが参考になると思います。

Entity

次にEntityを記述します。

@Entity(tableName = "user")
data class UserEntity constructor(
    @PrimaryKey(autoGenerate = true) //default false
    val id: Long = 0,

    @ColumnInfo(name = "user_name")
    val userName: String,

    val createAt: String
)
  • @Entityこれをつけることによって、Roomで利用できるEntityになります。tableNameはオプションで、クラス名と実際に生成されるTable名を変えたいときにつけます。
  • @PrimaryKeyは、1つのEntityに最低1つはつける必要があります。複数のPrimaryKeyを持つ場合は、以下のように@Entityアノテーション内に定義できます。
@Entity(primaryKeys = ["id", "user_name"])
  • autoGenerate = trueになっている場合、idに0を渡すと、自動的にincrementしてくれます。idを指定する必要がない場合は、0を指定してください。

  • @Ignoreを定義することによって、そのカラムを無視することができます。しかしdata classで@Ignoreを定義するとEntities and Pojos must have a usable public constructor.というエラーが吐かれます。これは全てのカラムにデフォルト値を指定することによって解消できます。

@ColumnInfo(name = "user_name")
var userName: String = "",

@Ignore
var createAt: String = ""

DAO

次にData Access Object(DAO)を記述します。

@Dao
interface UserDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertiOrUpdateUser(entity: UserEntity): Completable

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertiOrUpdateUsers(entities: List<UserEntity>): Completable

    @Query("SELECT * FROM user WHERE id = :id")
    fun findById(id: Long): Single<UserEntity>

    @Delete
    fun delete(entity: UserEntity): Completable
}
  • @Insertは、UserEntity単体、複数(List)のどちらでもInserすることができます。また、onConflictは、任意の値を指定することによって、PrimaryKeyが同じものがInsertされた場合の挙動を指定することができます。今回は上書きするようにOnConflictStrategy.REPLACEを指定しました。その他の指定については こちら のサイトが参考になると思います。

  • @Insertの戻り値はCompletableを指定できます。

  • @Deleteでは引数にUserEntityを渡すことによって、渡したEntityを削除することができます。この戻り値もCompletableを指定することができます。

Query

@Queryの戻り値にはMaybe, Single, Flowable/Observableを指定することができます。各々挙動が違うので、要件に合ったものを選ぶといいと思います。

Maybe

@Query("SELECT * FROM user WHERE id = :id")
fun findById(id: Long): Maybe<UserEntity>
  • データベース内に該当するUserEntityがない場合、何も返さずにonCompleteが呼ばれます。
  • データベース内に該当するUserEntityがある場合、onSuccessが呼ばれ、終了します(onCompleteは呼ばれません)。
  • Maybeが終了した後にUserEntityが更新されても何もしません。

Single

@Query("SELECT * FROM user WHERE id = :id")
fun findById(id: Long): Single<UserEntity>
  • データベース内に該当するUserEntityがない場合、onError(EmptyResultSetException)が呼ばれます。
    • データベース内に該当するUserEntityがないかつ、List型を返す場合はonError(EmptyResultSetException)が呼ばれず、空のListが返されます。
  • データベース内に該当するUserEntityがある場合、onSuccessが呼ばれ、終了します。
  • Singleが終了した後にUserEntityが更新されても何もしません。

Flowable/Observable

@Query("SELECT * FROM user WHERE id = :id")
fun findById(id: Long): Flowable<UserEntity>
  • データベース内に該当するUserEntityがない場合、何も返さず、何も呼ばれません。
  • データベース内に該当するUserEntityがある場合、onNextが呼ばれます。
  • UserEntityが更新されるたびに、自動でonNextが呼ばれます。

Database

次にDatabaseを記述します。

@Database(entities = [UserEntity::class], version = SampleDatabase.DATABASE_VERSION)
abstract class SampleDatabase : RoomDatabase() {

    abstract fun userDao(): UserDao

    companion object {
        const val DATABASE_VERSION = 1
        private const val DATABASE_NAME = "sample.db"

        private var instance: SampleDatabase? = null

        fun init(context: Context) {
            Room.databaseBuilder(context, SampleDatabase::class.java, DATABASE_NAME)
                .build().also { instance = it }
        }

        fun getInstance() = instance
    }
}
  • RoomDatabaseを継承したabstractクラスを作成します。
  • @Database内のentitiesにアプリ内で使うEntityを宣言します。Entityが増えるたびに、ここに追加していく形になります。
  • @Database内のversionにデータベースのversionを定義します。最小値は1です。
  • abstract fun userDao(): UserDaoの部分はDAOの宣言になります。DAOが増えるたびに、ここに追加していくことになります。
  • init()やgetInstance()を生やしておき、外部から呼び出せるようにしておきます。

Databaseの初期化

最後に上で定義したDatabaseをApplicationクラスで初期化します。

class SampleApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        SampleDatabase.init(this)
    }
}

簡単ですね。これでSampleDatabaseをどこからでも呼び出せるようになりました。

使ってみる

それでは準備が整ったので、実際に使ってみようと思います。
今回は
1. 複数のUserEntityをInsert
2. idからUserEntityを取得
3. 2で取得したUserEntityを削除
この3つの処理を書いていこうと思います。

val users: List<UserEntity> = //任意の値を入れてください

SampleDatabase.getInstance()?.let { sampleDatabase ->
    val userDao = sampleDatabase.userDao()

    userDao
        .insertiOrUpdateUsers(users)
        .andThen(userDao.findById(1)) //Single<UserEntity>を返しています
        .flatMapCompletable { user -> userDao.delete(user) }
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe({}, { e -> e.printStackTrace() })
}

たったこれだけです! すごくシンプルに書けます。

  • subscribeOn()ここだけ説明しておきます。Room実行は基本的にBackgroundThreadを指定しないといけません。MainThreadで実行するとExceptionが吐かれます。もしMainThreadでの実行を許可したい場合は、RoomのInstance生成時に以下のような定義が必要になります。
Room.databaseBuilder(context, SampleDatabase::class.java, DATABASE_NAME)
    .allowMainThreadQueries() //MainThreadでの実行を許可します
    .build().also { instance = it }

最後に

RoomはThread制限があったり、Queryが間違っていたらBuild時に教えてくれたり、とても親切なライブラリです。
皆さんも是非使ってみてください。

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

IntelliJ IDEA で Ktor を使ってみた

IntelliJ IDEA で Ktor のアプリケーションを作った時のメモ。

Ktorとは

JetBrains社(Kotlinを開発している企業)が提供する軽量なwebフレームワーク。
公式サイト

Ktor プラグインのインストール

「Configure」 -> 「Plugins」 を選択する。
スクリーンショット 2019-03-23 18.51.19.png

プラグイン管理画面が表示されるので、検索ボックスに「Ktor」と入力後、Enterキーを押下する。
スクリーンショット 2019-03-23 18.52.07.png

検索を実行すると、Ktorプラグインが表示されるので、「Install」を押下する。
スクリーンショット 2019-03-23 18.52.29.png

インストール完了後、「Restart IDE」を押下するか、「OK」を押下する。
「OK」押下の場合、IDEの再起動を催促される。
スクリーンショット 2019-03-23 18.52.40.png

アプリケーションの作成

「Create New Project」を押下する。
スクリーンショット 2019-03-23 18.53.22.png

プラグイン一覧の「Ktor」を選択後、「Next」を押下する。
スクリーンショット 2019-03-23 18.58.16.png

特に変更せず、「Next」を押下する。
スクリーンショット 2019-03-23 18.58.47.png

プロジェクト名を入力後、「Finish」を押下する。
スクリーンショット 2019-03-23 18.59.14.png

「Create directories for empty content roots automatically」にチェックを入れる。
※ 「Create directories for empty content roots automatically」にチェックを入れておくと、自動で必要なディレクトリ作ってくれる。
スクリーンショット 2019-03-23 19.00.23.png

プロジェクトが作成されるので、「Application.kt」を編集する。
スクリーンショット 2019-03-23 19.04.01.png

Application.kt
package com.example

import io.ktor.application.*
import io.ktor.response.*
import io.ktor.request.*
import io.ktor.routing.get      // GET用
import io.ktor.routing.routing  // ルーティング用

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
    routing {
        get("/") {  // ルートパスの定義
            call.respondText("Hello Ktor!!")
        }
    }
}

「Application.kt」の編集後、アプリケーションを起動する。
スクリーンショット 2019-03-23 20.08.46.png

起動に成功すると、デフォルトでは「localhost:8080」で立ち上がる。
スクリーンショット 2019-03-23 20.11.27.png

※ ちなみに、ポート番号やデータベースの接続先情報は「application.conf」に定義する。
スクリーンショット 2019-03-23 20.13.08.png

ブラウザやPostman等で、「localhost:8080」にアクセスする。
スクリーンショット 2019-03-23 19.10.53.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DroidKaigi 公式アプリのKotlin Multiplatform

どこでもKotlin #7 〜Kotlin MPP特集〜で話す予定の内容です!他の方の発表面白そうですし、先着順でまだ空きがあるようなので参加まだの方いれば早めにどうぞ!
https://m3-engineer.connpass.com/event/123055/

概要

自分の知見というより、DroidKaigiアプリとしての知見になりますが、このままではもったいないので、書いておきます。
このあたり、自分はAndroid版の機能をレビュー、実装している間に、コントリビューターの方々がやっていただけたものになります。 @kikuchy さんが特にこの基盤作りをメインにやってくれていました。他にもiOSの有名な方々など、さまざまな方が開発に参加していただけました。

DroidKaigiではAndroid版とiOS版をリリースしています。そこで培った知見を公開しておこうと思います。

https://itunes.apple.com/jp/app/droidkaigi-2019/id1450771424?mt=8

Kotlin Multiplatformとは

Kotlin Multiplatformを使うとAndroidとiOSでコードを共通化することが出来ます。
例えば、DroidKaigi 2019のアプリではKotlin Multiplatformを使って、APIの呼び出しや、アプリ内で使うモデルのクラスたちを共通化しています。現状のKotlin Multiplatformを使った開発では、UIの共通化を行うことはあまり考えられておらず、UIの部分はiOSであればSwiftで開発し、AndroidではKotlinまたはJavaを用いて開発を行い、APIやDBなどの部分を共通化するのが普通です。
これはいい面も悪い面もあります。UI、UXはプラットフォームに合わせて最適化しやすいのですが、逆に共通化できる部分は結構少なくなります。

Androidでのビルド

KotlinのコードはJavaと同様にJavaのクラスファイル、バイトコードに変換され、それがDalvikバイトコードに変換されます。
Kotlin Multiplatformのコードも同様に変換されます。何も問題なく動作します。

iOSでのビルド

Kotlin/Nativeによって、ネイティブバイナリにKotlinを変換することができ、JVMなどの仮想環境がないiOSでも実行することが出来ます。
基本的には以下のようなGradleタスクを動かして、iOSのFrameworkを作成して、使います。

task packForXCode(type: Sync) {
    final File frameworkDir = new File(buildDir, "xcode-frameworks")
    final String mode = project.findProperty("XCODE_CONFIGURATION")?.toUpperCase() ?: 'DEBUG'
    def target = project.findProperty("kotlin.target") ?: "iOS"

    inputs.property "mode", mode
    dependsOn kotlin.targets."$target".compilations.main.target.binaries.findFramework("", mode).linkTask

    from { kotlin.targets."$target".compilations.main.target.binaries.findFramework("", mode).outputFile.parentFile }
    into frameworkDir

    doLast {
        new File(frameworkDir, 'gradlew').with {
            text = "#!/bin/bash\nexport 'JAVA_HOME=${System.getProperty("java.home")}'\ncd '${rootProject.rootDir}'\n./gradlew \$@\n"
            setExecutable(true)
        }
    }
}

そしてこのGradleタスクをXcodeのBuild Phasesで指定してあげることで、作ってあげることが出来ます。
image.png

使ったライブラリ

Kotlin Multiplatformのために現在、さまざまなライブラリが開発されており、DroidKaigiではKtor-ClientやTimber、Klockなどのライブラリを用いて開発を行いました。これを使うことで、Multiplatformで例えばAPIの呼び出しの処理を共通化出来たりします。

Ktor-Client

https://ktor.io/clients/http-client.html

Kotlin Multiplatformで使えるHttpClient。 OkHttpと連携できたり、GsonやKotlinx.Serializationと連携できる。インターフェースはRetrofitのほうがいい感じ。

Kotlinx.Serialization

https://github.com/Kotlin/kotlinx.serialization

Kotlin Multiplatformで使えるJSON, CBOR, Protobufフォーマットが使えるシリアライザ。gsonやmoshiの代わりに使う。

Klock

Kotlin Multiplatformで使える日付や時間を表現できるライブラリ。これを使わない場合は、自分でそれぞれのプラットフォームのDate型とかを定義しないといけないのでだいぶ助かります。JetBrains公式ではなく、今後JetBrains公式のものを考えているようなので、それが出るまでのつなぎになりそうな気がしています。

Timber

com.jakewharton.timber:timber-common:5.0.0-SNAPSHOTを使うことで、Androidで有名なログライブラリのTimberを使えます。
現状、Kotlin/Native(iOS向け)には対応していないので注意が必要

DroidKaigiでの構成

DroidKaigiではAPI呼び出しの部分とModel部分を共通化しています。
image.png

Kotlin MultiplatformとMulti Module

なぜこのような構成になっているのか見ていきましょう。
まず最初に自分が作っていたときは機能を実装していかなくてはいけないので、APIやModelのモジュールを分けており、ModelはKotlin Multiplatform Project(MPP) モジュールで作っていました。
そこでiOS版を作るときに1つ問題が発生しました。

iOSのプロジェクトから複数のKotlin MPPモジュールを参照できませんでした。

image.png

image.png

https://twitter.com/kikuchy/status/1083999701160972290 より

そのため1つのモジュールで、それぞれのモジュールを参照できるモジュール ios-combinedを作成し、srcDirsを使って無理やり参照するという荒業により解決しました。

    sourceSets {
        final List<String> projectsList = [
                ":model",
                ":data:api",
                ":data:api-impl",
                ...
        ]
        commonMain {
            projectsList.forEach {
                kotlin.srcDirs += "${project(it).projectDir}/src/commonMain/kotlin"
            }

ただ、この構成も完璧ではありません。Android Studio 3.4以上で以下のエラーがAndroid Studio上で起こることが観測されています。現状は、settings.gradleを変更しながら開発する必要があります。
https://github.com/DroidKaigi/conference-app-2019/issues/738

Kotlin MultiplatformとDagger

Kotlin MPPモジュールではプラットフォーム依存のコードも入れることが出来、Kotlin MPPモジュール内のAndroidのモジュール内限定になりますが、Daggerに関連するコードも書くことが出来ます。

この中の一番下の部分だけDaggerが使えます。

そのため、以下のように継承するなどすることで、配布することが出来ます。

Kotlin MPPモジュール内

interface DroidKaigiApi {
    suspend fun getSessions(): Response
...
}
open class KtorDroidKaigiApi constructor(
    val httpClient: HttpClient,
    val apiEndpoint: String,
    val coroutineDispatcherForCallback: CoroutineContext?
) : DroidKaigiApi {
    override suspend fun getSessions(): Response {
... // Ktorの処理は本筋ではないので省略
    }

api-impl/src/main(Android用のフォルダ内)

class InjectableKtorDroidKaigiApi @Inject constructor(
    httpClient: HttpClient,
    @Named("apiEndpoint") apiEndpoint: String
) : KtorDroidKaigiApi(httpClient, apiEndpoint, null)
internal abstract class ApiModule {
    @Binds abstract fun DroidKaigiApi(impl: InjectableKtorDroidKaigiApi): DroidKaigiApi

Kotlin MultiplatformとKotlin Coroutines

iOSアプリからKotlinのsuspend functionを呼ぶことは通常できません。
そのため最初は以下のようにコールバックを作成し、実装していました。
これを1つずつ繰り返すのはかなり骨が折れる作業ではないでしょうか :sob:

    override fun getSessions(
        callback: (response: Response) -> Unit,
        onError: (error: Exception) -> Unit
    ) {
        GlobalScope.launch(requireNotNull(coroutineDispatcherForCallback)) {
            try {
                val response = getSessions()
                callback(response)
            } catch (ex: Exception) {
                onError(ex)
            }
        }
    }

Deferredを返す関数を作っておけば、iOSからDeferred.invokeOnCompletion()を呼び出せば可能であることに着目し、Kotlinx_coroutines_core_nativeDeferredに対するextension functionを生やすことでRxSwiftのSingleを返せるようなメソッドを作り、それを使うことで @nukka123 さんが通信を可能にしてくれました。

https://github.com/DroidKaigi/conference-app-2019/pull/601

Kotlin MPPのコード

    override fun getSessionsAsync(): Deferred<Response> =
        GlobalScope.async(requireNotNull(coroutineDispatcherForCallback)) {
            getSessions()
        }

Swiftの呼び出しコード

        return ApiComponentKt.generateDroidKaigiApi()
            .getSessionsAsync()
            .asSingle(Response.self)

SwiftのDeferredに対するextension function

import Foundation
import RxSwift
import ioscombined

extension KotlinThrowable: LocalizedError {
    public var errorDescription: String? {
        return self.message ?? "No message. \(self)"
    }
}

extension Kotlinx_coroutines_core_nativeDeferred {

    func asSingle<ElementType>(_ elementType: ElementType.Type) -> Single<ElementType> {
        return Single<ElementType>.create { observer in
            self.invokeOnCompletion { cause in
                if let cause = cause {
                    observer(.error(cause))
                    return KotlinUnit()
                }

                if let result = self.getCompleted() as? ElementType {
                    observer(.success(result))
                    return KotlinUnit()
                }

                fatalError("Illegal state or invalid elementType.")
            }

            return Disposables.create {
                self.cancel()
            }
        }
    }
}

Kotlin MultiplatformとDynamic Feature Module

AndroidではDynamic Feature Moduleという後からモジュールを読み込むことで、アプリを小さくできる仕組みがあります。これを組み込もうとしたときに以下のようなエラーが出ました。

ZipException: duplicate entry: META-INF/ktor-client-core.kotlin_module

これはktor-client-core-1.1.2.jarktor-client-core-jvm-1.1.2.jarで同じktor-client-core.kotlin_moduleが含まれているため起こっていると思われます。
現在、以下のissueを作成しており、S1の優先度で対応していただいているので、そのうち直ると思われます。
https://issuetracker.google.com/issues/125696148

Parcelizeとの連携

ParcelableはAndroidでIntentにインスタンスを乗せてデータを送ったりするときに使う仕組みです。ParcelizeはAndroid向けのParcelableを簡単に作ってくれるKotlinの機能です。そのParcelizeは工夫することで、Kotlin Multiplatform Moduleの中でも使えます。
詳しくは以下の最高の記事があるのでそれをご覧ください。
https://aakira.app/blog/2018/12/kotlin-mpp-android-parcelable/

ハマったポイント(うろ覚え)

Kotlin/Nativeの対応ArchitectureによるiOSでのリリースの制限

iOSのリリースの前日、Undefined symbols for architecture armv7というエラーに悩まされる。そもそもサポートされていなかったので除外して解決。(古いiPhoneでは動かない模様)
https://github.com/JetBrains/kotlin-native/issues/1460

Kotlin MultiplatformのクラスがAndroid Studio上で赤くて解決されない

enableFeaturePreview('GRADLE_METADATA')がないとダメ。


https://github.com/DroidKaigi/conference-app-2019/pull/81

Gradle Metadataにもバージョンがあって、Gradle 4.7のMetadataとそれ以降のMetadataがあり、現状は、新しいバージョンのメタデータに大体のライブラリが対応しているので、それを使うと問題なく利用できます。(うまくビルドできないときは確認してみると良さそう)

ちなみに、Gradle 5.3からGradle module metadataは1.0になるみたいでまたライブラリのアップデート必要そうかもです :sweat_smile:
https://github.com/gradle/gradle/blob/de88b30e5374ede4dc393f5709fa71a7f349785e/subprojects/docs/src/docs/design/gradle-module-metadata-1.0-specification.md

まとめ

iOSの実装など、ほとんど自分の力ではないのですが、さまざまな人の力によりDroidKaigiのKotlin Multiplatformの実装がされました。このようにPRによって知見が集まってくることは、個人的にはかなりすごいことだと思っています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Kotlinで、Androidとブラウザーで動くアプリを作ったのでハマったこととか共有

背景

GitHubの草原を是が非でも絶やしたくない!!!!

2019-03-keep-me-contributing-contribution-map.png

... ので、こんな感じのリポジトリーを作って毎日やったことを簡単に記録しています。
技術書をちょっと読んだだけでも更新して良いことにしているので、よほどのことがない限りは更新する条件が満たせるはずです。
少しズルっぽく見えるかも知れませんが1、結果的に私のモチベーションを維持するのに大変役立っています。

しかし、そんなリポジトリーを用意してもなお、体調を著しく崩した日などは、条件を満たしていても更新し忘れることがあるので、更新をチェックするアプリを作りました、というのが今回のお話です2
ハマった部分などを力の限り共有しますので、Kotlin/JSで作ったアプリなどとして参考にしていただければ。

作ったもの

?こちらにそのブラウザー版がございます。

https://keep-me-contributing.igreque.info/

2019-03-keep-me-contributing-contribution-map-browser-app.png

READMEに書いたとおり、フォームにGitHubのユーザー名やリポジトリー名、それからGitHubのPersonal Access Tokenを書いて「Start Checking」をクリックすれば、後は自動で数分おき(現在は5分で固定しています)に対象のリポジトリーの最新のコミット日時をチェックし、今日の0時0分よりも前の日時を指して入れば警告を出す、それだけのアプリです。
ブラウザー版は、Google Chromeの「ショートカットを作成」機能から「ウィンドウとして開く」を使ってインストールすることで、デスクトップの端っこに置いておくウィジェットとして使うのがおすすめです(Firefoxにも同じ機能が欲しいなぁ...)。

リポジトリーは?こちらです。

https://github.com/igrep/keep-me-contributing-kt

作るのに使ったもの

タイトルでも触れたとおり、Kotlin(ver. 1.3です念為)でAndroidアプリを作り、Kotlin/JS(「KotlinJS」と表記するのもよく見かけるのでそれも併記しておきます)でブラウザー版のアプリを作りました。
Android版とブラウザー版とで共有する部分を独立したライブラリーとして切り出すことで、できるだけコードを再利用するよう務めました。

十分に単純なアプリケーションだったので、それ以外に特筆すべきライブラリーはほとんど使っていません。
DIフレームワークもVirtual DOMもRxなんちゃらも何も使ってません!
強いて言えば、GraphQLのクライアントとしてapollo-androidを使ったり、テスト用にJUnit 5などを使った、ぐらいでしょうか。
その辺の今どきなライブラリーの使用例として期待していた方はすみません?。

あ、ライブラリーではありませんが、kotlin-dce-jsというツールは使いました。
Kotlin/JSが生成したJavaScriptの、dead code elimination、つまり使用してないコードを削除するツールですね。
これのおかげで1MB以上あるKotlin/JSの標準ライブラリーも、250KBまで減らせました。
build.gradleにちょっと書くだけで使えるので楽ちんでした☺️。
ちなみに、今回はWebpackは使いませんでしたが、このkotlin-dce-js, 1モジュールごとに1つのJavaScriptファイルを出力する、という仕様のようなので、依存しているモジュールの数が多くなった場合や、NPMにあるJavaScript製のパッケージも使いたくなった場合は、やっぱりWebpackと組み合わせて1つのJSファイルにまとめるようにするべきでしょう。
その辺はまた機会があれば。

デプロイするのに使ったもの

最近話題のNetlifyです。
以前に私のブログを移行する際に行ったのと同様に、どうせ現状私しか編集しなさそうなアプリケーションなので、netlify-cliを使って手元のマシンからデプロイすることにしました。
Netlify上でビルドすることも設定すればできるのでチャレンジはしてみたのですが、どうもうまくいかなかったので諦めています。
ただこの運用、後で「ハマったところ」でも触れますが、どうもNetlifyの期待しているやり方ではないように見えるので、なんとかしたいところではありますが。

ハマったところ

タイムゾーン JSTが取得できない

直したコミット 1
直したコミット 2

Kotlinは、標準ライブラリーとしては日時を扱うクラスを提供しないため、JavaやJavaScriptなどの対象となるプラットフォームが提供するクラスを使う必要があります。
そして、Androidをターゲットにする場合はAndroid 7.0未満を切り捨てない限り、Java 8でできた便利な日時用のクラスを使えません。
悪名高いJava 7以前のDateCalendarを使いたくないという場合、Joda-Timeでも使うしかないわけです。
私は依存モジュールを増やしたくないあまり、古いCalendar等を使っていたのですが、それが失敗だったのかも知れません。

タイムゾーンを取得するコードとして、こちらを参考に、当初次のように書いていたのですが、なぜかUTCが返ってきてしまい、ひどく混乱しました。

TimeZone.getTimeZone("JST")

デバッガーでgetTimeZoneメソッドの中を追ってみたところ、どうやらこのメソッド、指定したタイムゾーンがなかった場合はUTCを返すという、非常に困った実装のようです?。例外を投げてくれた方が遙かにありがたいです。
詳しい事情は知りませんが、AndroidではこのJSTという名前は無効だったみたいで、必ずUTCが返ってきてしまいます。
実はOS依存なのかも知れません。

他のサイトでは下記の修正後のように大抵Asia/Tokyoと書いていたのですが、JSTの方が短いし、個人的に親しみのある言い方だからそうしていたんですが...。

TimeZone.getTimeZone("Asia/Tokyo")

Android OS起動時に、コミット日時のチェックが実行されない

直したコミット

これは、私が仕様を勘違いしているのかも知れませんが、Androidにおいて、バックグラウンドで定期実行を行う際はandroid.app.job.JobInfo.Builderというクラスで定期実行に関わる諸々の設定を書く必要があります(参考)。

その際、setPersisted(true)と指定することで、端末が再起動した後も定期実行をできるようになる... はずだったんですが、手元のエミュレーターやスマホで試した限り、なぜかうまくいきませんでした。
必要なパーミッションもちゃんと設定していたはずですし、LogCatとにらめっこしても特にヒントになるようなメッセージが見当たらなかったので、しかたなく、自前で端末の起動時に定期実行の登録を行うよう設定しました。
詳細は直したコミットをご覧ください。
もちろんAndroidManifest.xmlに<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>と書くのもお忘れなく!

共通ライブラリーのbuild.gradleで、ビルド対象のプラットフォームごとにKotlinの標準ライブラリーを依存モジュールとして指定しなければならない

直したコミット

Kotlinで、JavaScriptやJVMなど、複数のプラットフォームをターゲットにしたアプリケーションを作る場合、プラットフォーム間で共通して使用するコードを、独立したモジュール(以下、「共通ライブラリー」と呼びます)にする必要があります。
その場合、「共通ライブラリー」は必然的に複数のプラットフォーム向けにコンパイルされることになります。
そのためか、「共通ライブラリー」が外部のまた別のモジュールに依存している場合、プラットフォームごとに依存モジュールを別々に記述しなければならないことがあります。
下記のstdlib-*などと書かれた名前のモジュール(すなわち各プラットフォームごとのKotlin標準のモジュール)などがそれに該当します。
抜粋して例示します。

build.gradle
kotlin {
    ...
    sourceSets {
        commonMain {
            dependencies {
                implementation kotlin('stdlib-common')
                implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core-common:1.1.1"
            }
        }
        jvm().compilations.main.defaultSourceSet {
            dependencies {
                implementation kotlin('stdlib-jdk7')
            }
        }
        js().compilations.main.defaultSourceSet {
            dependencies {
                implementation "org.jetbrains.kotlin:kotlin-stdlib-js:$kotlin_version"
                implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core-js:1.1.1"
            }
        }
    }
}

これ、単にこのように依存しているモジュールを書けば良いと言うだけのものなのですが、書き忘れていた場合、IDEは何も教えてくれず、Gradleで該当箇所をビルドするまでエラーが表示されないので、ちょっとわかりづらいです。
具体的には、例えば下記のようなものです。

e: KeepMeContributingKt\core\src\commonMain\kotlin\info\igreque\keepmecontributingkt\core\CheckTarget.kt: (10, 25): Unresolved reference: isNotBlank
e: KeepMeContributingKt\core\src\commonMain\kotlin\info\igreque\keepmecontributingkt\core\CheckTarget.kt: (10, 56): Unresolved reference: isNotBlank
e: KeepMeContributingKt\core\src\commonMain\kotlin\info\igreque\keepmecontributingkt\core\CheckTarget.kt: (10, 84): Unresolved reference: isNotBlank
e: KeepMeContributingKt\core\src\commonMain\kotlin\info\igreque\keepmecontributingkt\core\ContributionStatusChecker.kt: (32, 17): Unresolved reference: Pair
e: KeepMeContributingKt\core\src\commonMain\kotlin\info\igreque\keepmecontributingkt\core\ContributionStatusChecker.kt: (34, 17): Unresolved reference: Pair
e: KeepMeContributingKt\core\src\commonMain\kotlin\info\igreque\keepmecontributingkt\core\ContributionStatusChecker.kt: (37, 13): Unresolved reference: Pair

どうやら、isNotBlankという拡張メソッドやPairというクラスはKotlin標準のモジュールが提供しているため、それがないことになってしまうようです。

[未解決] Netlify上でgradle runDceKotlinJsしてもNO-SOURCEと表示されるだけで、ビルドできない

Netlifyでは、デプロイの際に実行するビルドコマンドを設定することで、リンクしているリポジトリーが更新された際、自動で配信するファイルのビルド・デプロイまでしてくれます。
よく使われる静的サイトジェネレーターはいい感じにサポートできているっぽいのですが、私の場合、先ほども触れたとおりkotlin-dce-jsを使ってKotlinからJavaScriptに変換しつつさらに軽量化したファイルをデプロイする必要があります。
他のCIサービスにあった、Android向け設定ファイルのテンプレートを参考に、?こんな感じのシェルスクリプトを書いてみました。

ANDROID_COMPILE_SDK="28"
ANDROID_BUILD_TOOLS="28.0.2"
ANDROID_SDK_TOOLS="4333796"
wget --quiet --output-document=android-sdk.zip https://dl.google.com/android/repository/sdk-tools-linux-${ANDROID_SDK_TOOLS}.zip
unzip -d android-sdk-linux android-sdk.zip
echo y | android-sdk-linux/tools/bin/sdkmanager "platforms;android-${ANDROID_COMPILE_SDK}" >/dev/null
echo y | android-sdk-linux/tools/bin/sdkmanager "platform-tools" >/dev/null
echo y | android-sdk-linux/tools/bin/sdkmanager "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null
export ANDROID_HOME=$PWD/android-sdk-linux
export PATH=$PATH:$PWD/android-sdk-linux/platform-tools/
chmod +x ./gradlew
# temporarily disable checking for EPIPE error and use yes to accept all licenses
set +o pipefail
yes | android-sdk-linux/tools/bin/sdkmanager --licenses
set -o pipefail

bash ./gradlew assemble -x lintVitalRelease
bash ./gradlew browser:runDceKotlinJs
mkdir -p public/dist/
cp browser/dist/*.graphql browser/dist/*.js public/dist/
cp -r browser/{css,img,index.html} public

しかし、なぜか肝心のbash ./gradlew browser:runDceKotlinJsを実行したところで、下記のようにrunDceKotlinJsの結果がNO-SOURCEとなってしまい、変換したJavaScriptが作成されません?。

+ bash ./gradlew browser:runDceKotlinJs
> Configure project :core
Kotlin Multiplatform Projects are an experimental feature.
> Task :core:compileKotlinJs UP-TO-DATE
> Task :core:jsProcessResources NO-SOURCE
> Task :core:jsMainClasses UP-TO-DATE
> Task :core:jsJar UP-TO-DATE
> Task :browser:compileKotlin2Js UP-TO-DATE
> Task :browser:runDceKotlinJs NO-SOURCE

いろいろ試してみたんですが、どうもこの問題だけは解決できませんでした...。
ひょっとしたら今私のPCでしかビルドできなくなってたりするんでしょうか...?

Netlifyが意図せずデプロイしてしまう

このプロジェクトのREADMEを書いているときに気づいたんですが、Netlifyはどうやら、リンクしているリポジトリーのmasterブランチ(厳密には、「Production branch」として設定されているブランチ)にpushすると、ビルド設定をどうしていようと、必ずデプロイを開始する、という仕様のようです。
結果、デプロイするディレクトリーに何もない状態だと、空っぽのディレクトリーがデプロイされてしまう、というデプロイ事故が起きてしまいます?。
私の場合、特に何も考えずにREADMEを更新していたところ、リロードしたときに突然404が返ってくる、という事態に陥ったので、非常に焦りました...。

この問題を回避するために、絶対にpushしないダミーのブランチを作り、それをNetlifyの「Deploy contexts」の設定で「Production branch」として設定する、という方法を試してみてます。
その場合は「Deploy previews」として「Don’t build deploy previews for pull requests」を、「Branch deploys」として「None」を設定するのを忘れずに(本当にpreview用のデプロイをしたい場合は別ですが)。
Netlifyのドキュメントを読む限り、これ以上に確実な方法は現状なさそうです。
ビルドはNetlify以外のCIサーバーからやりたい、という人には結構嫌な仕様ですね...?。
Netlifyにメールでも伺ってみたところ、サポートにお願いすれば、リポジトリーとのリンクを解除してくれるとのことで、そうすればpushした際のデプロイを完全になくすことができるとか。

敢えて解決していない問題

現状、このアプリケーションは私が使うことしか想定していません。
需要があるかわからないので...?

そのため、下記のような問題を抱えていますが、ひとまず放置することにします。
Pull requestは歓迎するので、もし使いたいという方がいらしたらぜひ。

  1. Androidアプリは作りましたが、Google Playに公開はしてません(これから挙げるような問題があるので)。
  2. タイムゾーンはJSTに限定しています。そのため、日本国外にお住まいの方が使うと予期せぬ時間が「0時0分」として扱われてしまいます。
  3. エラーハンドリングをあまりしっかりやってません。例えばGitHubのパーソナルアクセストークンを間違えた場合、エラーを表示するようにはできてますが、詳細な原因は一切教えてくれません。
  4. コミットの日時をチェックする際、「誰がそのコミットをしたのか」はチェックしてません。そのため、複数人でコミットするリポジトリーの場合は、間違った結果を表示する恐れがあります。
  5. GitHubしか現状サポートしてません。GitLabやBitbucketがお好きな方はすみません...?。
  6. そしてやっぱりiOS版はありません。私はiOS端末はおろかMacも持っていないので、やるとしたら、共通して使っているライブラリーをどこかのMavenリポジトリーに上げて、Kotlin/Nativeで誰かに作ってもらうしかありません。

  1. ⚠️なので当然っちゃ当然ですが、GitHubアカウントを見て採用の参考にする皆様はくれぐれもそこだけを見て判断することはないようお気を付けください! 

  2. 実は数年前にも同じようなものを作りました。こちらはContribution Mapが書かれたSVGを直接パースしてコミットをしたかどうかチェックしていたのですが、いかんせん公開されているAPIではないため、細かい仕様変更に振り回されたり、CORSポリシーが設定されておらずブラウザーだけで完結させることができないといった問題があったりしたため、今回作り直すことにしました。あと、Androidのネイティブアプリにすることで、より私の目に付きやすい位置にチェック結果を出したかった、というのもあります。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む