2014年のAndroid開発者がJetpack Composeを学ぶ

こんにちは。Xtoneのアプリエンジニアの田中です。

この度、12年ぶりにAndroidアプリ開発に復帰するにあたり、昔のXMLによるUI定義と、現代のJetpack ComposeによるUI定義に大きなギャップを感じたため、同じくギャップを感じている人に向けてこのブログを執筆します。

私の経歴 「Android → Unity → Android」

簡単に自己紹介をさせてください。

私は2011年にAndroidアプリ開発を独学で学び、同年末にスマホアプリエンジニアとしてSES企業に転職しました。

それから3-4年後、スマホのパズルゲームが世を席巻している頃にUnityでのアプリ開発に触れ、ソシャゲや非ゲームのUnityアプリを作ってきました。

そして現在に至っては、生成AIによるプログラミングという大きな変革の波が訪れ、生成AI活用に積極的であるXtoneに入社し、過去のAndroid経験を活かしてFlutterやAndroidのアプリチームに配属されました。

こうして、約12年ぶりにAndroidの世界に戻ってきたのです。


時代は宣言的UIへ

「今はReactもSwiftUIも、みんな宣言的UIの方向に進化してるんですよ」 と、Androidチームのメンバーからフィードバックをもらっていましたが、とは言え、Flutterアプリの開発をこなしつつ、モダンなAndroid開発についてキャッチアップする日々で、その時はまだピンときていませんでした。

でもある時、ふと気づきました。 Flutterの宣言的UIも、Jetpack Composeも、方言は違えど似た書き方になっているんです。どこも宣言的UIへと進んでいることを実感できて、腑に落ちました。

言語やOSは違っても、UI思想が一緒というのは有難いことだと思いませんか? 2014年頃はそれぞれの環境の書き方を覚える必要がありましたが、今ではマインド共通点が多いので、身につけてしまえば生成AIによるコーディングでもUI実装の迷いはかなり抑えられると思います。

XMLレイアウトやuGUIは「要素同士の関係性」を定義する作法でしたが、宣言的UIは「状態とUIの対応」を定義する作法へと変わり、この作法が優れているからこそ各プラットフォームも採用しているのだろうと感じました。


昔の主流 Android XML Layout

2010~2020年頃にAndroid開発をやっていた方なら、XMLでレイアウトファイルを書いて、RelativeLayoutやLinearLayoutでパズルのように画面を組み立てていた記憶があると思います。

Android XMLレイアウトの例

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="<http://schemas.android.com/apk/res/android>"
    xmlns:app="<http://schemas.android.com/apk/res-auto>"
    xmlns:tools="<http://schemas.android.com/tools>"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <RelativeLayout
        android:layout_width="fill_parent"
        android:layout_height="300dp"
        android:background="#FFDDDD"
        android:padding="12dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" >

        <ImageView
            android:id="@+id/iconImage"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:background="#EEAACC" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginLeft="12dp"
            android:orientation="vertical"
            android:layout_toEndOf="@+id/iconImage"
            android:layout_alignParentEnd="true"
            android:layout_alignBottom="@+id/iconImage">

            <TextView
                android:id="@+id/textCharaName"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_weight="1"
                android:gravity="center_vertical"
                android:background="#55DDAA"
                android:textSize="20sp"
                android:text="キャラ名:ゴブ太" />

            <TextView
                android:id="@+id/textCharaRace"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_weight="1"
                android:gravity="center_vertical"
                android:background="#55DDDD"
                android:textSize="20sp"
                android:text="種族名:ゴブリン" />
        </LinearLayout>

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginTop="12dp"
            android:layout_below="@+id/iconImage"
            android:layout_alignParentEnd="true">

            <TextView
                android:id="@+id/textCharaDetail"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:background="#66AADD"
                android:textSize="20sp"
                android:text="もっとも一般的な部類のモブキャラ。\\nゲームスタート時の街の外で出現したり、近場の洞窟へ行くと別種のゴブリンが出てくるなど、様々なキャラが存在する。" />
        </LinearLayout>

    </RelativeLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

XMLコードで定義したUIレイアウト

こんな程度のブロックのレイアウトでも、そこそこの行数になります。

Android XMLレイアウトは、視覚的に画面を組み立てるというよりは、「このTextViewはこのImageViewの右に配置して...」「このLinearLayoutの中に縦に並べて...」と、パズルのように関係性を定義していく作業でした。

当時もレイアウトエディタはありましたが、正直あまり便利ではなく、結局XMLコードを直接打ち込んで、レイアウトエディタは結果確認のために使う、という開発スタイルでした。

ConstraintLayoutやRelativeLayoutで制約を設定するのに苦労したり、プレビューと実機の表示が微妙に違ったりと、なかなか大変です。

でも、これが当時の「普通」でした。

今のAndroid開発はまったく違います。Jetpack Composeという宣言的UIフレームワークが主流になり、XMLレイアウトファイルはもう書きません。すべてKotlinでコードベースです。

この記事では、Android→Unity→Androidと技術スタックを渡り歩いてきた私が、「今のAndroid開発ってこうなってるんだ!」という驚きと、技術の変遷について書いていきます。


現在のAndroidのUI実装 「Jetpack Compose」

そして2025年、Xtoneに入社してAndroidチームに配属となり、モダンなAndroid開発のキャッチアップをしている真っ最中です。 「今時はJetpack Composeですよ」とのことで、先ほどのAndroid XMLレイアウトを、KotlinでJetpack Composeで書き直してみます。

Jetpack Composeで書くとこうなります

@Composable
fun CharacterCard() {
    // 全体を囲むBox(背景色とパディングを設定)
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .background(Color(0xFFFFDDDD))
            .padding(12.dp)
    ) {
        // 全体を「縦」に並べる
        Column {
            // 上段:アイコンと名前エリア
            Row(
                modifier = Modifier.fillMaxWidth()
            ) {
                // アイコン画像
                Box(
                    modifier = Modifier
                        .size(100.dp)
                        .background(Color(0xFFEEAACC))
                )
                Spacer(modifier = Modifier.width(12.dp))

                // 右側のコンテンツ(キャラ名・種族名)
                Column(
                    modifier = Modifier
                        .height(100.dp) // アイコンの高さに合わせる
                        .weight(1f)
                ) {
                    Text(
                        text = "キャラ名:ゴブ太",
                        fontSize = 20.sp,
                        modifier = Modifier
                            .weight(1f)
                            .fillMaxWidth()
                            .background(Color(0xFF55DDAA))
                            .wrapContentHeight(Alignment.CenterVertically)
                    )
                    Text(
                        text = "種族名:ゴブリン",
                        fontSize = 20.sp,
                        modifier = Modifier
                            .weight(1f)
                            .fillMaxWidth()
                            .background(Color(0xFF55DDDD))
                            .wrapContentHeight(Alignment.CenterVertically)
                    )
                }
            }

            Spacer(modifier = Modifier.height(12.dp))

            // 下段:詳細テキスト
            Text(
                text = "もっとも一般的な部類のモブキャラ。\\nゲームスタート時の街の外で出現したり、近場の洞窟へ行くと別種のゴブリンが出てくるなど、様々なキャラが存在する。",
                fontSize = 20.sp,
                modifier = Modifier
                    .fillMaxWidth()
                    .background(Color(0xFF66AADD))
            )
        }
    }
}

上記コードで定義したUIレイアウト

何が変わったのか?

  • XMLファイルが消えた: すべてKotlinコードで完結
  • 宣言的UI: 「どう見えるべきか」を記述(命令的な「どう変更するか」ではなく)
  • Composable関数: UIパーツを関数として定義し、組み合わせる
  • Modifier: スタイルやレイアウト指定が統一的に

個人的には、Row/Column/Boxという名前が直感的で分かりやすいと感じました。LinearLayoutのorientationを指定するより、RowかColumnか明示的に書く方がコードを読んだときに理解しやすいです。


イベント実装と単方向データフロー

レイアウトの変化だけでなく、イベントハンドリングの方法も大きく変わりました。

昔のAndroidはコードとXMLが分離していた

public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main); // XMLレイアウトを読み込む

        // XMLで定義されたボタンをIDで探す
        Button button = findViewById(R.id.myButton);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // クリック時の処理
            }
        });
    }
}

または、XMLから直接メソッドを呼ぶ方法

<Button
    android:id="@+id/myButton"
    android:onClick="onButtonClick"
    android:text="クリックしてください" />

どちらも、XMLとコードが分離しているため、「このボタンのクリック処理はどこ?」と探したことがあると思います。

今はJetpack ComposeでUIとイベントが一体化

Jetpack Composeでは、UIとイベントハンドラを同じ場所に定義します。

Button(
    onClick = {
        // クリック時の処理を直接書く
    }
) {
    Text("クリックしてください")
}

これが、単方向データフロー(Unidirectional Data Flow)の基本です。

単方向データフローとは?

現代のUI開発では、「状態は下に流れ、イベントは上に流れる」という原則があるのをご存知でしょうか。 一般的なコンピュータサイエンスでは、機械に近い層を「低レイヤー」、人間に近い層を「高レイヤー」と呼びます。基本情報技術者で習う概念です。

  • 低レイヤーはCPU、メモリ、DB、Repository
  • 高レイヤーはUI、画面、アプリケーション

この認識でいくと、イベント(ボタンクリック等)は UI → Repository へ流れるので、高レイヤーから低レイヤーへ「下る」と解釈するところですが、「Events flow up(イベントは上に流れる)」と表現されます。

このギャップの正体は、アーキテクチャ図の「視覚的な配置」を基準にした表現にあります。

  • UI を図の「上」もしくは「外周」に配置
  • Repository を図の「下」もしくは「中心」に配置

という描き方が一般的です。

下の図では中心を上外側を下として「up/down」を表現しています。 一般的なレイヤーの概念とは逆の表現になるので、最初は戸惑います。でも、宣言的UIで使われる「Events flow up」という英語表現は業界全体で定着しているので、「そういう言い方をするもの」と覚えてしまうのが早いです。

Clean Architectureと単方向データフロー


典型的なアーキテクチャの層構造

// 【Repository層】データの取得・保存
class CharacterRepository {
    suspend fun getCharacter(id: String): Character {
        // API呼び出しやDB取得
        return api.fetchCharacter(id)
    }
}

// 【ViewModel層】状態管理とビジネスロジック
class CharacterViewModel(
    private val repository: CharacterRepository
) : ViewModel() {
    // 状態(State)を保持
    private val _character = MutableStateFlow<Character?>(null)
    val character: StateFlow<Character?> = _character.asStateFlow()

    // イベントを受け取り、状態を更新
    fun onCharacterSelected(id: String) {
        viewModelScope.launch {
            _character.value = repository.getCharacter(id)
        }
    }
}

// 【UI層】画面表示とユーザー操作
@Composable
fun CharacterScreen(
    viewModel: CharacterViewModel = viewModel()
) {
    // 状態を受け取る(下に流れる)
    val character by viewModel.character.collectAsState()

    Column {
        Text(text = character?.name ?: "読込中...")

        // イベントを上に送る(上に流れる)
        Button(onClick = {
            viewModel.onCharacterSelected("character_001")
        }) {
            Text("キャラを読み込む")
        }
    }
}

アーキテクチャを踏まえたデータの流れ

  • 状態は下に流れる(State flows down)
    • Repository(図のEntities) → ViewModel (図のPresenters)→ UI
    • ViewModelが保持するcharacterの状態がUIに渡される
    • UIは受け取った状態を表示する
  • イベントは上に流れる(Events flow up)
    • UI → ViewModel → Repository
    • ボタンクリックなどのイベントがViewModelに通知される
    • ViewModelがRepositoryを呼び出し、状態を更新

この原則により、以下のメリットがあります。

  • データの流れが追いやすい:一方向なので、どこから来てどこへ行くのか明確
  • バグが減る:状態の変更箇所が限定される
  • テストしやすい:各層を独立してテストできる

昔のAndroidでは、ActivityやFragmentが複数の責務を持ち、データの流れが複雑になりがちでした。現代のアーキテクチャでは、このように各層の責務が明確に分離されています。


まとめ

Android XMLレイアウト→ Unity(uGUI)→ 宣言的UI(Jetpack Compose、Flutter)という具合にUIフレームワークをいくつか経験してきましたが、昔はプラットフォーム固有の実装方法の色が濃く、AndroidやiPhoneやUnityそれぞれの標準実装がありました。しかし現在では宣言的UIの潮流によって、細かな関数名やコンポーネント名などに差はあっても、概念が共通していることで実装者の負担が小さくなってきたと思います。

一つ一つの技術で学んだ考え方は、次の技術でも活きています。AndroidのRelativeLayoutの考え方がUnity uGUIで役立ち、宣言的UIへのパラダイムシフトを経て、作法を学び、Flutterでの業務経験がJetpack Composeへの理解を深めました。

この記事が、技術の変化に戸惑っている方や、新しいキャリアに挑戦したい方の参考になれば嬉しいです。