フル Kotlin で Mastodon クライアントを作ってみて

  • 11
    いいね
  • 0
    コメント

自己紹介

  • tomoya0x00
    • GitHub/Twitter/Qiita/mstdn.jp
  • メインは組み込み系
  • 一年ちょっと前に車載業界から小規模のベンチャーに転職
  • 最近は業務でAndroid/iOSのアプリ開発も
    • 実務経験は少ないのでお手柔らかに…

Mastodon クライアント開発中

troutoss.png


なぜKotlin?

  • 型推論
  • null安全
  • スコープ関数
  • 拡張関数
  • Collections(Java8相当のStream)

などなど…本当の理由は


Swift書いてたらJava書くのが辛くなってきた!


Kotlinを使ってみて

  • 便利だったこと

  • ちょっとはまったこと


便利だったこと

  • Realmとの親和性
  • ActivityのExtraをlazyで遅延取得
  • 拡張関数でUtilityクラス乱立防止
  • coroutineで簡単非同期処理

Realmとの親和性 - Entityの定義が楽

// Mastodonのアカウント情報
open class MastodonAccount(
        @PrimaryKey open var uuid: String = UUID.randomUUID().toString(),

        open var instanceName: String = "",
        open var userName: String = "",
        open var accessToken: String = ""
) : RealmObject()
  • コンストラクタの引数にval/varでプロパティ化
  • デフォルト引数対応しているので、デフォルト値の定義も楽
  • ただし、Kotlinのクラス・プロパティはデフォルト継承不可なので修飾子open付与が必要

Realmとの親和性 - プロパティ名の参照

// Mastodonのアカウント情報取得
fun loadAccountOf(instanceName: String, userName: String): MastodonAccount? =
        Realm.getDefaultInstance().use { realm ->
            realm.where(MastodonAccount::class.java)
                    .equalTo(MastodonAccount::instanceName.name, instanceName)
                    .equalTo(MastodonAccount::userName.name, userName)
                    .findFirst()?.let { realm.copyFromRealm(it) }
        }
  • クラス名::プロパティ名.name でプロパティ名をStringとして取得可能
    • JavaだとEntity内にstatic finalで手動定義するか、自動生成するか?

ActivityのExtraをlazyで遅延取得

// Status投稿用のActivity
class PostStatusActivity : AppCompatActivity() {

    lateinit private var binding: ActivityPostStatusBinding
    lateinit private var viewModel: PostStatusViewModel

    private val accountType: AccountType by lazy {
        intent.extras.getString(EXTRA_ACCOUNT_TYPE)?.let { AccountType.valueOf(it) } ?: AccountType.UNKNOWN
    }

    private val accountUuid: String by lazy { intent.extras.getString(EXTRA_ACCOUNT_UUID) }
    private val replyToId: Long? by lazy {
        if (intent.extras.containsKey(EXTRA_REPLY_TO_ID)) intent.extras.getLong(EXTRA_REPLY_TO_ID) else null
    }
    private val replyToUsers: Array<String>? by lazy { intent.extras.getStringArray(EXTRA_REPLY_TO_USERS) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_post_status)
        viewModel = PostStatusViewModel(accountType, accountUuid, replyToId, replyToUsers)
        binding.viewModel = viewModel
    }
}
  • 関数lazyで遅延初期化できる
  • Extraの抽出処理をlazyでくるんであげると普通のプロパティっぽく使える

拡張関数でUtilityクラス乱立防止

// Status添付メディアの実際のタイプ
// remote-only attachmentの場合、"unknown"となるっぽいのでURLから推測する
fun Attachment.actualType(): String {
    return when (this.type) {
        "image", "video", "gifv" -> this.type
        else -> {
            val mimeType = URLConnection.guessContentTypeFromName(this.actualUrl()) ?: ""
            when {
                mimeType.startsWith("image") -> "image"
                mimeType.startsWith("video") -> "video"
                else -> "unknown"
            }
        }
    }
}

coroutineで簡単非同期処理

// タイムラインの更新処理
fun refresh() = async(CommonPool) {
    // getTimeline = (Range) -> Single<Pageable<Status>>
    // 最新の Status 20個を非同期で取得
    pageable = getTimeline(Range(limit = 20)).await()
    pageable?.let {       
        statuses.clear()
        statuses.addAll(it.part)
        launch(UI) { notifyDataSetChanged() }        
    }
}
  • 同期処理と同じように非同期処理が書けて可読性が良い
  • 特にSingleでJSのPromise的に処理している箇所はcoroutineで事足りるのでは?
  • 開発中のMastodonクライアントでは一行もRxJavaのコードを書いてません
  • Goのgoroutineのようにchannelもあるので、より複雑な非同期処理も書きやすそう
  • 参考:第5回Kotlin勉強会 async/awaitで快適非同期ライフ @kkagurazaka

ちょっとはまったこと

  • 無名クラスのインスタンス化
  • BaseObservableを継承してData Binding
  • coroutineの例外処理
  • coroutineのUIスレッド実行
  • coroutineのライフサイクル管理

無名クラスのインスタンス化

RecyclerView.Adapterのクリック時メソッドをoverrideしたインスタンスを得たい

// MastodonのAttachment用アダプター
open class MastodonAttachmentAdapter(private val attachments: List<Attachment>) : 
        RecyclerView.Adapter<MastodonAttachmentAdapter.ViewHolder>() {

    open protected fun onClickImage(urls: Array<String>, index: Int) {}
    open protected fun onClickVideo(url: String) {}
    open protected fun onClickUnknown(url: String) {}
}
val attachmentAdapter: MastodonAttachmentAdapter =
        object : MastodonAttachmentAdapter(showableStatus.mediaAttachments) {
            override fun onClickImage(urls: Array<String>, index: Int) {
                messenger.send(ShowImagesMessage(urls, index))
            }

            override fun onClickVideo(url: String) {
                messenger.send(OpenUrlMessage(url))
            }

            override fun onClickUnknown(url: String) {
                messenger.send(OpenUrlMessage(url))
            }
        }

BaseObservableを継承してData Binding

Kotlinはプロパティを定義するとsetter/getterを自動生成する…bindingのアノテーションはどう書くの?

// Mastodonのステータス用ViewModel
class MastodonStatusViewModel(private val status: Status) :
        BaseObservable() {

    @get:Bindable
    val isBoost: Boolean
        get() = status.reblog != null
}

coroutineの例外処理

普通にtry catchでいけます

class MastodonTimelineFragment : Fragment() {

    lateinit private var binding: FragmentMastodonHomeBinding
    private var adapter: MastodonTimelineAdapter? = null

    private fun onRefresh() = launch(UI) {
        binding.timeline.setRefreshing(true)
        try {
            adapter?.refresh()?.await()
        } catch (e: Exception) {
            Timber.e("refresh failed: %s", e)
            Toast.makeText(getContext(), R.string.comm_error, Toast.LENGTH_SHORT).show()
        } finally {
            binding.timeline.setRefreshing(false)
        }
    }

}
  • launch(){}をtry catchでくくっても例外キャッチできないので注意

coroutineのUIスレッドでの実行

RxJavaのように実行するスレッド指定・切替はどうするの?

// タイムラインの更新処理
fun refresh() = async(CommonPool) { // common thread pool で実行
    // getTimeline = (Range) -> Single<Pageable<Status>>
    // 最新の Status 20個を非同期で取得
    pageable = getTimeline(Range(limit = 20)).await()
    pageable?.let {       
        statuses.clear()
        statuses.addAll(it.part)
        launch(UI) { notifyDataSetChanged() } // UIスレッドで実行       
    }
}

coroutineのライフサイクル管理

ActivityのonDestroy時にcoroutineをキャンセルするには?

class MainActivity : AppCompatActivity() {
    val job: Job = Job() // the instance of a Job for this activity

    override fun onCreate() {
        launch(job + CommonPool) {
            // very long and cancellable operation
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        job.cancel() // cancel the job when activity is destroyed
    }

    // the rest of code
}
  • 参考:Lifecycle and coroutine parent-child hierarchy
  • ただし、複数のcoroutineでjobを継承してどれか一つでもキャンセル(例外をtry catchし忘れた場合も)すると他のcoroutineが実行されない
    • キャンセル済みのjobで新たなcoroutineは実行できないっぽい
  • RxJava2のCompositeDisposableみたいに、launchの戻り(Job)をaddしていって一気にclearできるクラスを作った方が良いのかも?

Kotlinを使ってみて - まとめ

  • 便利だったこと

    • Realmとの親和性
    • ActivityのExtraをlazyで遅延取得
    • 拡張関数でUtilityクラス乱立防止
    • coroutineで簡単非同期処理
  • ちょっとはまったこと

    • 無名クラスのインスタンス化
    • BaseObservableを継承してData Binding
    • coroutineの例外処理
    • coroutineのUIスレッド実行
    • coroutineのライフサイクル管理