Edited at

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

More than 1 year has passed since last update.


自己紹介


  • tomoya0x00


    • GitHub/Twitter/Qiita/mstdn.jp



  • メインは組み込み系

  • 一年ちょっと前に車載業界から小規模のベンチャーに転職

  • 最近は業務でAndroid/iOSのアプリ開発も


    • 実務経験は少ないのでお手柔らかに…





Mastodon クライアント開発中



なぜ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のライフサイクル管理