💎

Material 3 やめました (2)

2023/09/25に公開

DroidKaigi 2023で行われたMaterial 3 やめましたスライド)というセッションを聴講した。
その内容に触発され、自分が関わっているAndroidアプリにおいても、Material 3をやめることにした。

なぜやめるのか

上記の発表と同様に、特に問題となったのは、ブランドカラーの取り扱いだった。多くのアプリには固有のブランドカラーが存在するが、その色調を(軽微であれ)変換することはブランドのイメージを損ねてしまう。仮に許容したとしても、iOSやWebでもMaterial 3を採用しないと、統一感を損ねてしまう。全てのプラットフォームでMaterial 3を採用する場合、より横断的な技術選定になり、各プラットフォームでのライブラリの成熟度や実装コストを考慮する必要がある。これは我々にとって現実的でなかった。

また、独自デザインシステムとMaterial 3の実装が混在することで、混乱を招いていたのもある。
本来は独自のデザインシステムとして存在すべきコンポーネントが、たまたまMaterial 3に近い機能を持っていたがためにMaterial 3として実装される、ということが何度かあった。これを許容してしまうと、開発者は、どちらのコンポーネントを使うべきか?という問いに毎回答えなければならない。

やめる とは

「Material 3をやめる」とはどういうことなのか。
誤解しやすいのは、Material 3への依存を全て取り除くことではない、ということだ。Material 3をやめた世界において、普段触れるコンポーネントは、必ず独自のコンポーネントである。ただし、その独自コンポーネントが、内部的にMaterial 3を使用して実装される可能性はある。

例えば、次のようなモジュールの依存関係を考える。

appモジュールはfeatureAとfeatureBに依存しており、featureモジュール群は共通のuiモジュールを利用している。そして、各モジュールではMaterial 3を使用している。

Material 3をやめたときの依存関係は、次のようになる。

ここで「やめる」というのは、featureモジュールがMaterial 3に直接依存しない、という状態を指している。こうすることで、uiに隠蔽されたMaterial 3の実装を使うことがあっても、feature内でandroidx.compose.material3.*を直接importすることは許されなくなる。

対象

実際にMaterial 3をやめるにあたり、対象となったAndroidアプリの特徴を整理する。

  • Viewを使用しない、Composeの新規開発のアプリ
  • iOS版は既にリリースされているが、Android版は未リリース
  • 独自のデザインシステムが既に存在している
    • 上記の経緯からiOS(+Web)を主に想定した設計
    • Android向けの最適化が不足している要素も(例:チェックボックス、モーダルなど)
  • 実装は独自のデザインシステムとMaterial 3が混在している状態
    • テーマは独自に定義
    • Buttonなどの代表的なコンポーネントも独自で定義
    • TextやIconなど、細かなコンポーネントはMaterial 3を採用
    • ModalBottomSheetやSnackbarといった、特定のコンポーネントでもMaterial 3を利用

未リリースで、独自のデザインシステムが確立されている。要するに、Material 3をやめるハードルが比較的低い状況にあるアプリと言える。

撤去作業

初めに、作業範囲を把握するための見積もり行った。大雑把に言って、import androidx.compose.material3.*の出現箇所を列挙すれば良い。
次にChatGPTが提案したコマンドを示す。

grep -r "import androidx.compose.material3\.[a-zA-Z0-9_]*" .

結果、次のコンポーネントで置換が必要だと判明した。

Text
TextButton
Icon
IconButton
Divider
Card
ModalBottomSheet
AlertDialog
Snackbar
NavigationBar

ここでは、Textを取り上げて説明する。まず、Material 3の代替として、新たなコンポーネントを共通のuiモジュールに定義する。

// Custom UI
@Composable
fun Text(
    text: String,
    modifier: Modifier = Modifier,
    textAlign: TextAlign? = null,
    color: Color = Color.Unspecified,
    overflow: TextOverflow = TextOverflow.Clip,
    maxLines: Int = Int.MAX_VALUE,
    style: TextStyle = LocalTextStyle.current,
) = androidx.compose.material3.Text(
    text = text,
    modifier = modifier,
    textAlign = textAlign,
    overflow = overflow,
    color = color,
    style = style,
)

その後、各featureモジュールでのmaterial3のimportを、新しいものに置き換える。

- import androidx.compose.material3.Text
+ import com.example.core.ui.designsystem.Text

基本的にはこの手順の繰り返しである。

例外として、appモジュールではMaterial 3への依存を許容することにした。ルートとなるappモジュールで定義されるコンポーネントはBottomNavigaitonのように、基本的に他のモジュールで再利用することがない。この場合、共通化するメリットは少なく、むしろ手間が増えることを懸念した。

例えば、次のようにappモジュールでしか使用しないScaffoldを定義する価値はあるだろうか?

// Custom UI
@Composable
fun RootScaffold(
    modifier: Modifier = Modifier,
    ...
) = androidx.compose.material3.Scaffold(...)

これは正しさというよりも、開発効率を考慮した意図的な手抜きである。

結果

実際にMaterial 3をやめてみて、どうだったか。

迷いが無くなった

システムとしてMaterial 3を使用できないことを担保したため、人的ミスの起こる余地が無くなった。例えば、カードのUIをデザインする際、以前はMaterial 3のCardを使うべきか、独自のCardが存在するのか、という判断に迷うことがあった。


(どっちのCard使うのか問題)

一般に、人間の努力でルールを厳格に守ることは開発規模が大きくなるにつれ、困難になる。しかし、Material 3をやめた後は、Material 3のCardがコード補完に表示されなくなり、Cardが一意に定まるので、こうした悩みが発生する余地を無くすことができた。

シンプルなインターフェース

いくつかのコンポーネントでは、インターフェースを簡素化することができた。

Iconの例を挙げると、全ての場所でpainterResourceを使用することが確認できたので、painterではなく、直接idを渡す形に変更した。

// Custom UI
@Composable
fun Icon(
    @DrawableRes resourceId: Int,
    ...
) = androidx.compose.material3.Icon(
    painter = painterResource(id = resourceId),
    ...
)

// Usage
Icon(resourceId = R.drawable.ic_foo, ...)

この変更により、painterResource(...)と毎回書く手間が省け、記述の短縮に繋がった。

次にTextの例を挙げる。Material 3のTextは様々な用途を想定しているため、非常に多くのパラメータが存在する。

// Material 3
@Composable
fun Text(
    text: String,
    modifier: Modifier = Modifier,
    color: Color = Color.Unspecified,
    fontSize: TextUnit = TextUnit.Unspecified,
    fontStyle: FontStyle? = null,
    fontWeight: FontWeight? = null,
    fontFamily: FontFamily? = null,
    letterSpacing: TextUnit = TextUnit.Unspecified,
    textDecoration: TextDecoration? = null,
    textAlign: TextAlign? = null,
    lineHeight: TextUnit = TextUnit.Unspecified,
    overflow: TextOverflow = TextOverflow.Clip,
    softWrap: Boolean = true,
    maxLines: Int = Int.MAX_VALUE,
    minLines: Int = 1,
    onTextLayout: (TextLayoutResult) -> Unit = {},
    style: TextStyle = LocalTextStyle.current
)

しかし、fontSize、fontWeightなどのパラメータはデザインシステムが存在する環境では、スタイル、タイポグラフィとして規定されるため、不要である。これらの不要なパラメータを取り除き、次のように定義した。

// Custom text
@Composable
fun Text(
    text: String,
    modifier: Modifier = Modifier,
    textAlign: TextAlign? = null,
    color: Color = Color.Unspecified,
    overflow: TextOverflow = TextOverflow.Clip,
    maxLines: Int = Int.MAX_VALUE,
    style: TextStyle = LocalTextStyle.current,
) = androidx.compose.material3.Text(
    ...
)

こうすることで、場当たり的にフォントの設定を変更するような「怪しいコード」の混入を未然に防ぐことができる。適切にパラメータを設計することで、デザインシステムに準拠することがシステム的に強制されるようになった。

正しい設計

副産物として、Composeのレイヤリングを強く意識するようになった。Material 3をやめることは、どこでMaterial 3を使用しているのか理解することに他ならないからだ。

例えば、DividerはMaterial 3のコンポーネントであるが、これをfoundation的に使用してしまっていることに途中で気づいた。Dividerがfoundationではなく、materialのレイヤにあることは、考えればわかることだが、何も考えずに普段実装していると、見落としがちである。このようにMaterial 3やめる過程で、曖昧に実装していたデザインシステムがしっかりと精査された感覚がある。そして、それらのコンポーネントをうっかり使用する余地を無くすことができた。

多くはMaterial 3の実装をラップすることになるのでは、という指摘

それはある。
例えば、Snackbar、Scaffoldのようなコンポーネントは、Material 3の機能をほぼそのままバイパスするような実装になった。冗長である、と言われればそうだが、Material 3のコンポーネントの数にも限度があるため、実装コストとしては大した問題に感じなかった。それ以上にラッパーとして再定義されることのメリットが大きいように感じる。(例えば、今後Material 4が出てくるかもしれないし、Material 3の中でも大きな変更が入る余地は十分にある)

まとめ

Material 3をやめることについて説明し、実際の移行例を紹介した。移行には一定のコストがかかるものの、結果として、設計が明確になり、依存関係も整理されるメリットがあった。

NOT A HOTEL

Discussion