9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Jetpack Composeがalpha版になったので改めてQiitaビューアを作ってみた

Last updated at Posted at 2020-12-03

この記事はNTTテクノクロス Advent Calendar 2020の4日目です。

こんにちは、NTTテクノクロスの戸部@etctaroと申します。
普段は社内でモバイルアプリ開発関連の技術支援や社内向けのノウハウ記事執筆、社内研修講師活動、ハンズオン会の開催などを行っています。

はじめに

  • Jetpack Composeについては昨年の記事でも取り上げました。
  • 一言で述べるなら、「Androidアプリ開発における新しいUIの実装方法」となります。
  • 詳しく知りたい方は公式サイトを読んでいただいたり、CodeLabで手を動かしたりしてみていただいても良いかと思います。
  • 昨年はプレビュー版が出たてホヤホヤでしたが、今年はアルファ版になり、少しずつ実用化が近づいてきた印象があります。
  • そんなこんなで昨年の記事も振り返りつつ、現在の仕様に合わせて再びQiitaビューアを作ることにいたします。

注意点

  • Android Studio 4.2はCanary16版、Jetpack Composeは1.0.0-alpha07版を使います。
  • いずれもバージョンアップなどで変更が入る可能性が十分に想定されますのでご留意ください。
  • ※なお、以下は動作確認のための最低限の実装となりますので、本格実装するのであればリファクタリングの呼吸壱の型全集中でお願いします。

目標

ところで昨年のコードは?

  • 昨年の記事の中で作成したコードについては、アップグレードが必要、というメッセージとともに各種ライブラリ類を更新することになりました。
  • State周りの仕様が変わっているのでビルドに失敗するはず・・・と思っていましたが無事エラーとなりました。
  • (と、書きつつも、一から作り直す想定だったのでどうしようということもありませんが。)

前提条件

以下の環境を前提とします。

  • Android Studio 4.2 (Canary 16)
  • Target Sdk Version 29
  • 通信、シリアライズについては、Ktorおよびkotlinx.serializationを使います。(そしてこの選択が鬼門に。)

昨年からの更新内容

大ハマりポイント

今年も相変わらず無事ハマりました。と言っても、1つ大きくハマったくらいで、あとは公式のガイドなどをみつつ進めていけたようには思います。

kotlinx.serialization + Jetpack Composeの組み合わせを同じモジュール内で実装した場合、ビルドが終わらない

  • Ktorで通信してその結果をシリアライズ、画面に反映する、という割とやりがちなことをやろうとしたところ、そもそもビルドのタスクが終わらないという状況に。

  • 被疑箇所を探るためにdependeniesなどを一つずつコメントアウトなどしながら確認。

  • 結論として、kotlinx.serializationのアノテーション(@Serializableなど)を使ったクラスをJetpack Composeを使ったプロジェクトに含めていると、ビルドできないことがわかりました。

  • Jetpack Composeのコードで実際に使っていなくても、Entityのクラスが同じモジュールにあるだけでもNGなようです。(少なくとも私の環境では。)

  • おや、と思いつつ、issueを探していたらまさにありました

    • そのうち修正するよ、とは書いていただいているので、本格的にはそれを待つことになるかもしれません。

    • 暫定的な対応としては、同じissueの中で以下の通り書かれている通り、別のモジュールで実装するのが良さそうです。(いいね1000個差し上げたい気分)

      This issue only exists when using compose+serialization if used within the same module. Temporary workaround is to just separate the components into different modules (ui/app+network/serialization).
      
  • ということで、シリアライズ+通信用の処理については別のモジュールに持っていくことにしました。

    • この記事が大変わかりやすかったので、そのまま参考に。

Step1: 通信、シリアライズ周りの実装

繰り返し述べているように、今回はkotlinx.serializationKtorを使います。
以下、バージョンについては一例です。状況に応じて最新化などが必要になります。

また、ハマりポイントでも書いた通り、この処理はUIとは別モジュールとしました。

moduleのbuild.gradleの変更

Serializationのプラグインを追加します。

plugins {
    ...
    id 'org.jetbrains.kotlin.plugin.serialization' version '1.4.10' // 追記
}

また、以下の依存関係を追加します。(モジュールのbuild.gradle)

dependencies {
...
    // Ktor
    implementation "io.ktor:ktor-client-core:1.4.1"
    implementation "io.ktor:ktor-client-serialization:1.4.1"
    implementation "io.ktor:ktor-client-android:1.4.1"

    // Serialization
    implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1"
}

Entityの作成

  • kotlinx.serializationを使ったdataクラスにしました。
@Serializable
data class QiitaArticle (
    @SerialName("id")
    val id: String,

    @SerialName("title")
    val title: String,

    @SerialName("url")
    val url: String
)

通信部分の実装

Qiitaの情報を取得するためにAPIを叩きます
QiitaのAPIからはJSONの形で結果を受け取るので、必要な要素だけシリアライズします。
また、HttpClientにはengineの設定が必要になります。エラーメッセージにも表示されますが、ここを参考にしました。

class QiitaApi {
    private val httpClient = HttpClient(Android) {
        engine {
            connectTimeout = 100_000
            socketTimeout = 100_000
        }
        install(JsonFeature) {
            val json = kotlinx.serialization.json.Json {ignoreUnknownKeys = true}
            serializer = KotlinxSerializer(json)
        }
    }

    companion object {
        private const val QIITA_URI = "https://qiita.com/api/v2/items?page=1&per_page=20"
    }

    suspend fun getAllArticles(): List<QiitaArticle> {
        return httpClient.get(QIITA_URI)
    }
}

UI側でこのモジュールをdependencyに追加する

dependencies {
    implementation project(":mylibrary")
    (以下略)
}

Step2: アプリのUI部分を実装する

さて、ここからはJetpack Composeの世界になります。

一つの記事を表すComposable

  • タイトルとURLを縦並びで表示し、これを1つの記事として表示します。
  • タップ時の処理をModifier.clickableで実装する点が昨年との違いです。これは楽。
@Composable
fun QiitaItem(title: String, url: String) {
    Row(Modifier.clickable(onClick = {})) {
        Column() {
            Text(text = title)
            Text(text = url)
        }
    }
}

複数の記事の一覧を表すComposable

  • 前項のQiitaItemをさらに縦並びで表示します。
  • スクロール可能な一覧とする場合は、ScrollableColumnを使うことで実現ができます。これもとても楽になりました。
@Composable
fun QiitaItemList(items: List<QiitaArticle>) {
    ScrollableColumn {
        for (item in items) {
            QiitaItem(title = "${item.title}", url = "${item.url}")
            Divider(color = Color.Black)
        }
    }
}

一画面分のComposableの実装

  • ここまでで実装したComposableと、FABを組み合わせます。
  • Scaffoldを使うことでFABも手軽に配置することができます。
  • FABのクリック時の処理などについては後述します。
@Composable
fun HomeScreen() {
    MaterialTheme {
        Scaffold(
                bodyContent = {
                    QiitaItemList(items = listOf())
                },
                floatingActionButton = {
                    FloatingActionButton(
                            onClick = {}
                    ) {
                        Text("reload")
                    }
                }
        )
    }
}

Step3: State(状態)の管理

  • 昨年は@Modelのアノテーションを付与することで監視可能な状態を表していましたが、現行の仕様では使用できません。
    • ※昨年のハマりポイントではModelListなるクラスを使っていましたが、こちらは現在はAPI上存在しません。
    • ※Stateについての公式ドキュメントはこちら
  • 監視可能なリストについては、mutableStateListOfを使うことができます。本記事ではこれを使います。
  • なお、State管理については他にも方法がありますが、ここでは特に考慮しません。
@Composable
fun HomeScreen() {
    val qiitaArticles = remember {mutableStateListOf<QiitaArticle>()}

    MaterialTheme {
        Scaffold(
                bodyContent = {
                    QiitaItemList(items = qiitaArticles)
                },
                floatingActionButton = {
                    FloatingActionButton(
                        onClick = {
                        }
                    ) {
                        Text("reload")
                    }
                }
        )
    }
}

Step4: FABの処理

  • FABをタップした際の処理を実装します。
    • 通信を実行する
    • APIの結果を取得する
    • 一つ前の項で用意したqiitaArticlesに対して、取得したQiitaArticleの一覧を反映します。
  • qiitaArticlesに変更が入ると、HomeScreenが書き換えられるという状態になります。
    FloatingActionButton(
        onClick = {
            MainScope().launch {
                try {
                        val articles = QiitaApi().getAllArticles()
                        qiitaArticles.clear()
                        qiitaArticles.addAll(articles)
                } catch (e: Exception) {
                    Log.e(e)
                }
            }
        }
    ) {
        Text("reload")
    }

Step5: 記事クリック時の処理

  • 記事をクリックした際に、そのURLをブラウザで表示しようと思います。
  • 昨年は結構無茶な実装をしましたが、Contextの取得は無理矢理やらずにContextAmbientを利用すれば比較的容易にできます。
  • Intentを投げるだけなら、これを使えば良いです。
  • ということで、QiitaItemRowonClickについて実装を行います。
@Composable
fun QiitaItem(title: String, url: String) {
    val context: Context = ContextAmbient.current

    Row(Modifier.clickable(onClick = {
        val uri = Uri.parse(url)
        val intent = Intent(ACTION_VIEW, uri)
        startActivity(context, intent, null)
    })) {
        Column() {
            Text(text = title)
            Text(text = url)
        }
    }
}

Step6: スタイルの変更

        Column() {
            Text(text = title, style = typography.h6)
            Text(text = url, style = typography.body2)
        }

ここまでの完成形

とりあえず完成

もう少し変更・試してみる

FABをカスタマイズ

  • FloatingActionButtonはアイコンを一つ表示するのに使われるようで、デフォルトだと円になりました。(この点は昨年と違う。)
  • 文字列のように少し横長にした方が良い場合はExtendedFloatingActionButtonを使います。
  • 本来はアイコンだけで使い方が伝わった方が良いかもしれませんが。
            floatingActionButton = {
                ExtendedFloatingActionButton(
                    onClick = {
                        // 略
                    },
                    text = {Text("reload")}
                )
            }

横長FAB

色味はともかく、去年と同じように文字に合わせて横長になりました。

  • テキストとアイコンを並べることもできます。
            floatingActionButton = {
                ExtendedFloatingActionButton(
                    onClick = {
                        // 略
                    },
                    text = {Text("reload")},
                    icon = {Icon(asset = Icons.Filled.Refresh)}
                )
            }

横長FAB with アイコン

UIテスト

昨年は諦めたUIテストですが、Jetpack Composeでも動かせるようになりつつあるので、試してみました。

  • dependencyの追加
dependencies {
    ()
    // forTest
    androidTestImplementation("androidx.ui:ui-test:$compose_version")
}
  • プロダクトコードへのラベル追加

Jetpack ComposeのUIテストを実施する場合、Composableのテキストを使って探すこともできますが、そもそもテキストを持たない場合(例えばアイコンやレイアウト用のComposableなど)は探せません。

このため、プロダクトコードに対してAccessibilityのテキストを付与することで、テストコード側でもこのComposableを探すことが可能となります。

    ScrollableColumn(
        modifier = Modifier.semantics {accessibilityLabel = "Item List"}) {
        ()
    }

    ExtendedFloatingActionButton(
        modifier = Modifier.semantics {accessibilityLabel = "Refresh Button"}
    )
  • テストコードの作成
class MainActivityTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Test
    fun test_RefreshButton_tap_once() {
        composeTestRule.setContent {
            SampleCompose100AlphaTheme {
                HomeScreen()
            }
        }

        // Nodeのツリーを出力
        composeTestRule.onRoot().printToLog("ForComposeUITest")

        // FABをAccessibilityで探す→タップ
        composeTestRule.onNodeWithLabel("Refresh Button").performClick()

        // とりあえず少し待つ
        Thread.sleep(5000)

        // Nodeのツリーを再び出力
        composeTestRule.onRoot().printToLog("ForComposeUITest")

        // Columnを探す→子が20個あるはず
        composeTestRule.onNodeWithLabel("Item List").onChildren().assertCountEquals(20)
    }
}
  • その他ハマったポイント

こんなエラーメッセージが出てテスト実行時に失敗しました。

2 files found with path 'META-INF/AL2.0' from inputs

メタ情報が重複している、ということですが、appのbuild.gradleに以下を追加して対応。

android {
    packagingOptions {
        exclude 'META-INF/AL2.0'
        exclude 'META-INF/LGPL2.1'
    }
}
  • ということで
    Jetpack Composeのuitest
    やったぜ。

  • UIテストについての雑感
    公式のドキュメントにも書かれていた通り、Espressoに近い、というのはまさしくその通りだと感じました。
    ただ、若干情報が不足している感はあり、ラベル付けの話などは自前で試行錯誤してみた結果です。
    (一応、APIはあったので、多分できるんだろうと思ってはいましたが。)

補足: Android Studioの更新対応(to Arctic Fox)

この記事を書いている時に丁度2020.3.1 Arctic Fox Canary1のリリースがありました。ここではマイグレーションについて補足します。

  • ポイント
    • Arctic Foxでプロジェクトを動かす場合はGradle Pluginの更新が必要
    • 新しいGradle Pluginで動かすためにはJDK11が必要
    • マイグレーションはAndroid Studioの指示に従うと楽

Arctic Foxでプロジェクトを動かす場合はGradle Pluginの更新が必要

  • Arctic Foxでは古いバージョン(4.2以前)のGradle Pluginは動作しません。ビルドしようとすると以下の通りエラーメッセージが表示されます。
The project is using an incompatible version (4.2.0-alpha16) of the Android Gradle plugin.
  • このため、バージョンをArctic Foxが対応する7系以降に変更する必要があります。
  • 新規にプロジェクトを作成した際には、rootのbuild.gradleは以下のように設定されています。このように設定してください。
    dependencies {
        classpath 'com.android.tools.build:gradle:7.0.0-alpha01'
        ...
  • また、対応するGradleの最小バージョンが6.7.1となりますので、こちらも更新が必要です。(gradle/wrapper/gradle-wrapper.properties)
distributionUrl=https://services.gradle.org/distributions/gradle-6.7.1-bin.zip

なお、以前のバージョンのAndroid Studioで動かす場合は、プラグインのバージョンの設定を対応する値に戻す必要があります。

新しいGradle Pluginで動かすためにはJDK11が必要

  • Gradle Pluginの7.0.0-alpha01(おそらくそれ以降も)を動かすためにはJDK11が必要です。8を利用していた場合は以下のエラーメッセージが表示されます。
Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8.
You can try some of the following options:
  - changing the IDE settings.
  - changing the JAVA_HOME environment variable.
  - changing `org.gradle.java.home` in `gradle.properties`.
  • JDKの設定については、Project Structureから変更します。MacOSならCommand + ;か、メニューのFile > Project Structureです。
    スクリーンショット 2020-12-02 13.44.07.png

マイグレーションはAndroid Studioの指示に従うと楽

  • プラグインの更新が必要なプロジェクトを開いた場合、またはビルド時のエラーメッセージでUpgrade to the latest versionというリンクが表示されるので、それをクリックするとUpgradeを促されます。

スクリーンショット 2020-12-02 13.14.04.png

  • 折角勧められているのでこれに従ってみますと以下の変更が入ります。
    • Gradleのバージョンが6.7.1に更新。
    • Gradle Pluginのバージョンが7.0.0-alpha01に更新。

この記事で作成したサンプルコード

  • 作成したサンプルコードはGitHubに掲載しています。

おわりに

  • ということで、今年もつらつらとJetpack Composeについて書いてきました。
  • 昨年やり残したUIテストもついに実行できてAdvent Calendarの記事としては満足なところです。
  • これからJetpack Composeがより広がっていくことになっていくと思いますので、そんな時に少しでも役に立てれば良いと思います。
  • ということで、皆様、良いJetpack Composeライフを。

令和TXコソコソ噂話

5日目は武井@thetakeiさんが開発とセキュリティの関係について語ってくださるようですよ。
明日もぜひご覧ください!

9
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?