読者です 読者をやめる 読者になる 読者になる

みんからきりまで

きりみんです。

Android用はてブクライアントMitsumineの2.5.0をリリースした話とか

Android Kotlin プログラミング

Mitsumine 2.5.0をリリースしました。
Mitsumineは趣味で開発しているAndroid用のはてなブックマーククライアントアプリです。
久しぶりのバージョンアップなので、開発にあたってのいろいろな話を書いてみようと思います。

Mitsumineについて

とりあえず宣伝しておきます。

play.google.com

Mitsumineは主に自分が使いやすいはてぶアプリとして開発しています。
多分一番のヘビーユーザーは僕だと思う。
はてブを快適に眺めるために必要な機能は一通り実装されています。
特に一覧画面に表示される情報は公式アプリよりも多くなっていて、これは僕が出来るだけ記事を開かずにどんどん流し読みしながら面白そうな記事を探したいからです。
一方でアプリ内ブラウザなどの自分に不要な機能は実装していません。

https://i.gyazo.com/4abefe309e4739222e020fd0176179e1.gif

バージョン2.5.0では前々から放置していた一部URLの読み込みに失敗するバグの修正の他、ブコメについたはてなスターの表示などを実装しました。

Mitsumineと公式アプリ

元々Mitsumineは公式のはてなブックマークアプリがまだあまりメンテナンスされていなかった頃に作ったアプリで、当時の公式アプリはスクロールするだけでクラッシュしたりと自分が使うのに不便すぎたので、自分用アプリとして作成しました。
しかし、最近ははてなにもAndroidエンジニアが増えてきたのか、公式アプリも頻繁にアップデートされるようになってきたようです。(めでたい事ですね)
なのでMitsumineはそろそろ積極的に開発する必要が無くなってきたかなーと思っています。自分が欲しい機能も一通り実装されているので、今後はバグ修正などのアップデートだけになるかもしれません。

まあ、僕は公式アプリよりMitsumineの方が見やすいので使い続けるんだけど。

実装の話

Mitsumineは僕が新しいライブラリや設計を試すためのサンドボックス的なプロジェクトでもあったりします。
例えばMitsumineのコードはKotlinにハマった時に全てKotlinに書き換えていて、今でもKotlinで開発しています。

そんな流れで去年の秋くらいからはMVP設計への全面書き換えを行っていたのですが、すでにコード量がかなり多くなっているMitsumineを全面的にMVPへ書き換える作業は思いのほかダルく、またMVP設計についても書けば書くほど「あれ、これ意味ないんじゃ」「もっとこうした方がいいな」と考えがまとまらず、結局半年近くもひたすら機能とは関係ないリファクタリング作業をやっているというわけの分からない状態になってしまいました。
たとえ趣味であっても、あまり大きいアプリに全面書き換えのような事をすると途中でモチベが死んで辛いという知見を得ました。

ただ、散々試行錯誤したおかげでMVP設計について自分の中でそれなりに妥協点が見つかったような気がします。

MVPについて

元々はCleanArchitectureを参考にして
・View
・Presenter
・UseCase
・Repository
の4レイヤーに分けるという設計でやっていたのですが、だんだんそれだと冗長なだけであまりメリットがない気がしてきて、最終的にはUseCaseを廃止して
・View
・Presenter
・Repository
の3レイヤーになりました。

この場合、ロジックは全てPresenterのレイヤーに収まる事になり、ViewやデータなどAndroidの仕様に依存する部分のみ別レイヤーに分ける事になります。
そしてViewとRepositoryをMockに差し替えたPresenterのテストを書く事で、とりあえずインプットとアウトプットを監視するテストをJUnitで走らせる事が出来ます。(あとは必要に応じてモデルクラスのテストも書く)
PresenterのテストだけだとViewレイヤーでのバグ(リスナーのセットし忘れやViewのレイアウト崩れなど)までは検知できませんが、大きなコストを掛けずにAndroidで書くテストとしてこんなものかなという気がしてます。

Mockitoを利用したPresenterのテスト例

class FeedPresenterTest {

    lateinit var viewMock: FeedView
    lateinit var repositoryMock: AbstractFeedRepository
    val presenter = FeedPresenter()
    val resultMock = listOf(Feed(title = "mock1"), Feed(title = "mock2"))

    @Before
    fun setup() {
        
        viewMock = mock()
        repositoryMock = mock()
        whenever(repositoryMock.requestFeed()).thenReturn(Observable.from(resultMock))
    }

    @Test
    @JvmName(name = "onCreate時にフィードを読み込みセットする")
    fun onCreateTest() {
        presenter.onCreate(viewMock, repositoryMock)
        verify(viewMock, times(1)).initViews()
        verify(viewMock, times(1)).showRefreshing()
        verify(repositoryMock, times(1)).requestFeed()

        verify(viewMock, times(1)).setFeed(resultMock)
        verify(viewMock, times(1)).dismissRefreshing()
    }

    @Test
    @JvmName(name = "PullToRefresh時にフィードを更新する")
    fun onRefreshTest() {
        presenter.onCreate(viewMock, repositoryMock)
        presenter.onRefresh()
        verify(viewMock, times(1)).clearAllItem()
        verify(viewMock, times(2)).showRefreshing()
        verify(repositoryMock, times(2)).requestFeed()

        verify(viewMock, times(2)).setFeed(resultMock)
        verify(viewMock, times(2)).dismissRefreshing()
    }

    @Test
    @JvmName(name = "フィードデータ取得失敗時にインジケータを停止する")
    fun onErrorTest() {
        whenever(repositoryMock.requestFeed()).thenReturn(Observable.error(Exception()))
        presenter.onCreate(viewMock, repositoryMock)
        verify(viewMock, never()).setFeed(anyList())
        verify(viewMock, times(1)).dismissRefreshing()
    }

    @Test
    @JvmName(name = "アイテムクリック時にURLをブラウザで開く")
    fun onItemClick() {
        presenter.onCreate(viewMock, repositoryMock)
        presenter.onItemClick(Feed(linkUrl = "http://test"))
        verify(viewMock, times(1)).sendUrlIntent("http://test")
    }

    @Test
    @JvmName(name = "アイテム長押し時にコメント一覧を開く")
    fun onItemLongClick() {
        val feed = Feed()
        feed.linkUrl = "http://test"
        feed.entryLinkUrl = "http://entry"

        // ブラウザで開く
        whenever(repositoryMock.isUseBrowserSettingEnable).thenReturn(true)
        presenter.onCreate(viewMock, repositoryMock)
        presenter.onItemLongClick(feed)
        verify(viewMock, times(1)).sendUrlIntent("http://entry")
        verify(viewMock, never()).startEntryInfoView("http://test")

        // ネイティブで開く
        whenever(repositoryMock.isUseBrowserSettingEnable).thenReturn(false)
        presenter.onItemLongClick(feed)
        verify(viewMock, times(1)).sendUrlIntent("http://entry")
        verify(viewMock, times(1)).startEntryInfoView("http://test")
    }

    @Test
    @JvmName(name = "シェアボタン押下と長押しでタイトルを付けるかを切り替える")
    fun onFeedShareClick() {
        val feed = Feed()
        feed.title = "title"
        feed.linkUrl = "http://test"
        feed.entryLinkUrl = "http://entry"

        // 押下
        // タイトル入り設定
        whenever(repositoryMock.isShareWithTitleSettingEnable).thenReturn(true)
        presenter.onCreate(viewMock, repositoryMock)
        presenter.onFeedShareClick(feed)
        verify(viewMock, times(1)).sendShareUrlWithTitleIntent("title", "http://test")
        verify(viewMock, never()).sendShareUrlIntent("title", "http://test")

        // タイトル無し設定
        whenever(repositoryMock.isShareWithTitleSettingEnable).thenReturn(false)
        presenter.onFeedShareClick(feed)
        verify(viewMock, times(1)).sendShareUrlWithTitleIntent("title", "http://test")
        verify(viewMock, times(1)).sendShareUrlIntent("title", "http://test")

        // 長押し
        // タイトル入り設定
        `when`(repositoryMock.isShareWithTitleSettingEnable).thenReturn(true)
        val presenter = FeedPresenter()
        presenter.onCreate(viewMock, repositoryMock)
        presenter.onFeedShareLongClick(feed)
        verify(viewMock, times(1)).sendShareUrlWithTitleIntent("title", "http://test")
        verify(viewMock, times(2)).sendShareUrlIntent("title", "http://test")

        // タイトル無し設定
        `when`(repositoryMock.isShareWithTitleSettingEnable).thenReturn(false)
        presenter.onFeedShareLongClick(feed)
        verify(viewMock, times(2)).sendShareUrlWithTitleIntent("title", "http://test")
        verify(viewMock, times(2)).sendShareUrlIntent("title", "http://test")
    }
}
class EntryInfoPresenterTest {

    lateinit var viewMock: EntryInfoView
    lateinit var repositoryMock: EntryInfoRepository
    lateinit var contextMock: Context
    lateinit var resultMock: EntryInfo
    val presenter = EntryInfoPresenter()

    @Before
    fun setup() {
        viewMock = mock()
        repositoryMock = mock()
        contextMock = mock()

        val bookmarks = listOf(
                Bookmark("test1", listOf("TagA"), "", "comment", "", emptyList()),
                Bookmark("test2", emptyList(), "", "", "", emptyList()),
                Bookmark("test3", listOf("TagB", "TagC"), "", "comment", "", emptyList()),
                Bookmark("test4", listOf("TagB"), "", "", "", emptyList())
        )
        resultMock = EntryInfo("testA", 4, "http://sample", "http://thum", bookmarks)
        whenever(repositoryMock.requestEntryInfoApi(any(), any())).thenReturn(Observable.just(resultMock))
    }

    @Test
    @JvmName(name = "onCreate時にページ情報を取得し表示する")
    fun onCreateTest() {
        whenever(repositoryMock.isLogin()).thenReturn(false)
        presenter.onCreate(viewMock, repositoryMock, "http://sample", contextMock)
        verify(viewMock, times(1)).initActionBar()
        verify(repositoryMock, times(1)).requestEntryInfoApi(contextMock, URLEncoder.encode("http://sample", "utf-8"))

        // 取得したものが設定される
        verify(viewMock, times(1)).setEntryInfo(resultMock)
        // 非ログイン時は対象ページのブクマ登録Fragmentは設定されない
        verify(viewMock, never()).setRegisterBookmarkFragment("http://sample")
        // コメントありは2件
        verify(viewMock, times(1)).setCommentCount("2")
        // タグは多い順にカンマ区切り
        Assert.assertEquals(resultMock.tagListString(), "TagB, TagA, TagC")
        verify(viewMock, times(1)).setViewPagerSettings(currentItem = 1, offscreenPageLimit = 2)
    }

    @Test
    @JvmName(name = "ログイン時にはブクマ登録Fragmentが追加される")
    fun onNextTestWithLogin() {
        whenever(repositoryMock.isLogin()).thenReturn(true)
        presenter.onCreate(viewMock, repositoryMock, "http://sample", contextMock)
        verify(viewMock, times(1)).setEntryInfo(resultMock)
        verify(viewMock, times(1)).setRegisterBookmarkFragment("http://sample")
    }
}

コード全体はGitHubにあげているので興味があればどうぞ。(試行錯誤しまくってたのでチグハグになっていますが)

github.com