Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

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

More than 3 years have passed since last update.

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

by tomoya0x00
1 / 19

自己紹介

  • 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のライフサイクル管理
tomoya0x00
元は車載関連の組み込み屋。現在はAndroidアプリ作ってる。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away