この記事はNTTテクノクロス Advent Calendar 2019の9日目です。
こんにちは、NTTテクノクロスの戸部@etctaroと申します。
普段は、社内でモバイル関連開発の技術支援や社内向けのノウハウ記事執筆、社内研修講師活動などを行っています。
はじめに
- Jetpack ComposeはGoogle I/O 2019で発表されたAndroidアプリの宣言的UI実装のためのライブラリ群です。
- これまでAndroidで使われていたレイアウトエディタ + XMLによるUIの実現ではなく、FlutterやSwiftUIのように、画面の各UI部品を
@Composable
というアノテーションをつけた関数、およびその複数の組み合わせでUIを実現できます。 - Android Studio 4.0のCanary版にて利用できるようになりました。
- Google I/O直後は専用のIDEをリポジトリから取得する必要があり、若干ハードルが高かったところではありますが、IDEとして公式にサポートされることにより利用しやすくなりました。
- そのような経緯と、個人的な興味がありましたので、サンプル的なアプリ作成を通じて戯れて(と言う名目の人柱になって)みました。
注意点
- Android Studio 4.0、Jetpack Composeについてはプレビュー版であり、正式版では状況が変わることが予想されますので、その点はご注意ください。
こんなアプリを作ることにした
新しい開発手法が出てきたということで、社内の伝統に則り(?)、とりあえずQiitaのAPIを叩いて画面に一覧表示するViewerを作ってみようと思います。
要件としては以下のようなイメージ。
- アプリを起動すると、空の一覧画面とリロードボタンが表示される。
- リロードボタンを押すと以下の処理を実施する。
- Qiitaの記事取得のAPIを実行し記事の一覧(JSON)を取得する。
- 記事の一覧(JSON)をオブジェクト化する。
- 取得した記事の一覧を(一旦クリアしてから)画面に反映する。
- 記事の一覧をスクロールして表示できる
- 一覧をタップしたら、タップした記事のURLをブラウザで表示する
前提条件
以下の環境を前提とします。
- Android Studio 4.0 (Canary 4)
- Target Sdk Version 29
(ハマり)ポイント
- 宣言的に実装するのは割とわかりやすい。ただし、現時点では参考ドキュメントが不足。
- リストをStateとして保持したい場合にはModelListを使う必要がある。
- スクロールするためには、
VerticalScroller
とFlexColumn
を組み合わせる。 - 通信の処理はこれまでと同様にできる。
- Jetpack Composeでインテント・・・どうやって投げるの?
- UIテストは現時点では無理。
Step1: アプリ用のプロジェクトを作成する
- Android Studio 4.0(Canary)から新規にアプリのプロジェクトを作成しようとすると
Empty Compose Activity
というテンプレートが選べます。
- このテンプレートを選び、適宜プロジェクト名などを埋めプロジェクトを作成します。
Step2: アプリのUI部分を実装する
早速ですが、Jetpack ComposeでUI部分を実装します。
記事の一覧っぽいものを実装する
- Jetpack Composeでは
@Composable
というアノテーションをつけた関数の組みあわせでUIを表現します。 - 今回作ろうとしている例では、記事の一覧を
Text
、Row
、Column
というComposable
を組み合わせて作成することにします。 - 公式サンプルの
Greeting
の例に則り、Qiita記事の一行を表すComposable
を作ってみました。 - 一覧には
タイトル
と記事のURL
を表示します。
データクラスの作成
- 今回は必要となるQiitaの記事のtitleとurlだけを保持するdata classを作成します。
data class Qiita(val title: String, val url: String)
一つの記事を表すComposable
- 画面には
Column
を使い、各記事のタイトルとURLを縦並びで表示させることにします。 -
Row
はComposable
を横並びで表示する際に使います。この後に何かボタンを入れようと思ってとりあえず追加してみました。(が、この記事では最後まで何も使っていません。)
@Composable
fun QiitaItem(title: String, url: String) {
Row() {
Column() {
Text(text = title)
Text(text = url)
}
}
}
複数の記事の一覧を表すComposable
QiitaItem
を複数組み合わせて記事の一覧とします。
- データクラス
Qiita
のオブジェクトのリストを通信で取得する前提ですので、これを引数としました。 - 区切り線(
Divider
Composable)も追加してみました。 - 現時点の公式のAPIには
ListView
のようなクラスが見当たらないので、forループでQiitaItemを呼び出すようにしました。
@Composable
fun QiitaItemList(items: List<Qiita>) {
Column {
for (item in items) {
QiitaItem(title = "${item.title}", url = "${item.url}")
Divider(color = Color.Black)
}
}
}
一画面分のComposableおよび、Activityの実装
ここまでの情報を組み合わせて、一つの画面を示すComposableは以下の通りになります。
QiitaItemListの引数は適当なリストにしています。実際には、通信で取得した結果がここに入ることになります。
@Composable
fun HomeScreen() {
MaterialTheme {
QiitaItemList(listOf(Qiita("title1", "http://url1")))
}
}
また、このComposable
を使った、Activityの実装は以下の通りです。setContentに指定してあげるだけです。
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
HomeScreen()
}
}
}
プレビュー表示
@Composable
にさらに@Preview
というアノテーションを加えることで、Android Studio上で画面イメージを見ることもできます。
@Preview
@Composable
fun prevQiitaItem() {
QiitaItem("title1", "http://sample.co.jp/")
}
@Preview
@Composable
fun prevQiitaItemList() {
QiitaItemList(
listOf(
Qiita("title1", "http://sample.co.jp/1"),
Qiita("title2", "http://sample.co.jp/2"),
Qiita("title3", "http://sample.co.jp/3")
)
)
}
State(状態)の管理
アプリで保持している何かしらの値が変更されたのを検知し、画面に反映させる場合、FlutterのStateと同様にJetpack Composeの場合にもState
(状態)の概念を導入することができます。
CodeLabでは以下で紹介されています。
自作classまたはobjectで状態を管理する場合は、@Model
というアノテーションをつけることで、状態管理ができます。
今回はQiitaの記事の一覧を更新したいので、以下のようにQiitaのリストを持つModelをトップレベルで用意してみました。
@Model
object QiitaItemsStatus {
val dataList = ArrayList<Qiita>()
fun addItem(item: Qiita) {
dataList.add(item)
}
fun clearItem() {
dataList.clear()
}
}
では適当にボタンを用意して、addItemを呼んでみます。
@Composable
fun SampleAddClearButton() {
Column() {
Row() {
Button(text = "addItem", onClick = {
with(QiitaItemsStatus) {
addItem(Qiita("1", "aaa"))
println(dataList.toString())
}
})
Button(text = "clearItem", onClick = {
with(QiitaItemsStatus) {
clearItem()
}
})
}
}
}
→ 何も起こらない。正確には、ボタンを押すたびにdataListの中身は増えているようですが、画面に反映されません。
State(状態)の管理 r1
そこで、公式サンプルのJetNewsアプリを確認してみました。
@Model
object JetnewsStatus {
var currentScreen: Screen = Screen.Home
val favorites = ModelList<String>()
val selectedTopics = ModelList<String>()
}
- ModelListというクラスが使われていました。
詳細な情報が若干不足していますが、かなり怪しいのでこのクラスを使ってみることにします。
object QiitaItemsStatus {
val dataList = ModelList<Qiita>()
}
→ビンゴでした。dataListを更新すると、画面に反映できるようになりました。
- ポイント:
-
State
にリストを使う場合はModelList
を使う必要がある(らしい)
-
縦スクロール
- 今回作るようなアプリでは縦スクロールが必要となります。CodeLabでは特に触れられていませんでしたが、公式サンプルのJetNewsアプリを参照したところ、以下の通り対応することで垂直方向のスクロールが実装できます。
FlexColumn() {
flexible(flex = 1f) {
VerticalScroller {
FooComposable()
}
}
}
ネーミング的にはVerticalScroller
があれば良さそうですが、FlexColumnで囲む必要がありました。
FAB(FloatingActionButton)
FABはJetpack ComposeのAPIに用意されていますが、そのまま使うと画面の全体がボタンになります。以下のようにAlign
で囲むことで、Basic Activity
テンプレートのようなボタンになります。
Align(Alignment.BottomRight) {
Padding(padding = EdgeInsets(right = 16.dp, bottom = 16.dp)) {
FloatingActionButton(text = "reload", onClick = {
// タップ時の処理
})
}
}
ここまでで、UI周りでやりたいことの実装は概ねできました。
Step3: 通信の処理などを実装する
通信の処理
通信の処理については、既存の知識の踏襲で実装できますので、ここでは省略します。
ここでは、fuel(通信)およびmoshi(JSONのシリアライズ)というライブラリを利用して実装しました。詳しくは下記を参照してください。
- fuel: https://github.com/kittinunf/Fuel/tree/master/fuel#installation
- moshi: https://github.com/square/moshi#download
- fuelとmoshiの連携: https://qiita.com/naoi/items/144950ea422cea86274d
仮に、通信結果をシリアライズし、dataListに詰めるところまでの処理をfetchQiitaData()
という関数で実装したものとします。
この関数をFABのonClickで呼ぶようにします。
FloatingActionButton(text = "reload", onClick = {
fetchQiitaData()
})
記事クリック時の処理
記事の@Composable
をクリックした時、ブラウザで記事を表示するような機能をつけようと思います。
Row
などのように、クリック時の処理を記載するパラメータを持たない場合は、Clickable
で囲むことによってonClickパラメータに処理を記述することができます。
Clickable(onClick = {
//ここに記述する
}) {
Row() {
Column() {
Text(text = title)
Text(text = url)
}
}
}
さて、クリックできるのは良いですが、URLをブラウザで開くような処理はどのようにすれば良いでしょうか。
既存であれば、startActivityにIntentを渡してあげるような実装でできますが。
ここは、Jetpack Composeの世界だけではどうにもならなかったため、
以下のようにしてみました。
- これを
// トップレベルに記述
var callbackOnLinkClicked : (Any) -> Unit = { }
- こうして
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
callbackOnLinkClicked = {
startActivity(it as String)
}
setContent {
HomeScreen()
}
}
private fun startActivity(url: String) {
val uri = Uri.parse(url)
val intent = Intent(ACTION_VIEW,uri)
startActivity(intent)
}
}
- こうじゃ
@Composable
fun QiitaItem(title: String, url: String) {
Clickable(onClick = {
callbackOnLinkClicked(url)
}) {
// 略
}
ここまでの完成形
- 記事執筆時のAPI( https://qiita.com/api/v2/items )実行結果です。(内容に他意はありません。)
- 見た目が若干気になったので、少しだけ文字サイズを調整してみました。
Row() {
Column() {
Text(text = title, style = +themeTextStyle { subtitle1 })
Text(text = url, style = +themeTextStyle { caption })
}
}
そのうちやりたいこと
-
今回はとりあえず動かすことを優先させましたが、一つのファイル内で色々とやっているのでリファクタリングを行う必要があります。
-
JetNewsのサンプルを見ていると以下のようにしているように見えます。
- 一つの画面(タブで切り替えられたりする場合もそれぞれ1画面として数える)を1パッケージとしてディレクトリを切る。
- 一つの
@Composable
関数が肥大化しそうなら適宜関数を分ける。 - 一つの画面の中で
@Composable
が多くなったら適宜ファイルを分割する。 - Stateやスタイルについても別ファイルに抽出する。
このあたりは一般的なコーディングと同様かと思います。
-
-
見た目についてももう少しこだわりたいと思います。
おまけ: UIテストについて
おまけとしてEspressoなどのUIテストツールで現時点でテストが実施できるか試してみます。
reload
ボタンがあるので、このボタンをクリックするようにEspressoのコードを書いてみます。
@Test
fun Test_reloadButton() {
onView(
withText("reload")
).perform(click())
}
実施してみると、結果として、以下の通りボタンが見つからないというエラーになります。
androidx.test.espresso.NoMatchingViewException: No views in hierarchy found matching: with text: is "reload"
Espresso Test Recorder
を使って構造を見てみると以下の通り、Jetpack Composeの部分が全てViewGroup(AndroidComposeView)でまとめられている状態になっています。このため、ボタンを探せずエラーとなっているようです。
参考
おわりに・雑感
そこそこ動きそうなアプリを作ってみつつ、最後にオチをつけたところで、まとめです。
Jetpack Composeについては、まだまだ公式情報が不足しているところではありますが、現時点でもそこそこ動くアプリを作ることができました。
既存のAPIの知識を活かせばもっと複雑なアプリも作れそうです。
正式版になる頃には参考ドキュメントなど、もう少し改善されていることを期待しますが、公式のサンプルは現時点でもかなり参考になりそうな感触です。
ただ、画面遷移やIntentなどこれまでも大いに使われているAPIの扱いについてはもう少し明確になってほしいと思います。
といったところで、本日の記事は以上となります。
それでは、NTTテクノクロス Advent Calendar 2019 の10日目の記事も引き続きお楽しみください。