目次

目次

【Android】Jetpack Compose Runtime APIを使って宣言的に画像を処理する

chiaki.kyui
chiaki.kyui
最終更新日2025/12/01 投稿日2025/12/02

この記事は レコチョク Advent Calendar 2025 の2日目の記事となります。 

はじめに

こんにちは、レコチョクでAndroidアプリ開発をしている休井です。
いよいよ今年も終わりに近づいてますね。今年は初めて飛行機での遠征をしまして、福岡で日向坂46のライブを見てきました。
飛行機に苦手意識があったので新幹線での遠征しかしたことがなかったのですが、無事克服できたので来年はもっといろんなところにライブを見に行きたいです。

そして、今年はDroidKaigiにも初参加してきました。
たくさんセッションを聞いてどれも非常に興味深かったのですが、特にUIだけじゃないComposeの可能性 ━ 宣言的に奏でるメロディを見て、Composeの仕組みとその活用方法を知ることができ、自分でもやってみたい!と思いました。
ということで、今回はセッションで紹介されていたKoruriライブラリを参考に、宣言的に画像を処理してみようと思います。
Jetpack Compose Runtimeに関する詳しい説明は行いませんので、気になる方はぜひYouTubeに公開されているセッションをご覧ください!

なぜ画像処理?

セッションでは、音声処理の実装が紹介されていました。
データを受け取る → 処理する → 次に渡すという流れを見て、画像でも同じことができそうだなと思いました。
ちょうど業務で画像のぼかし処理を実装したこともあり、自分に馴染みのある題材で試してみることにしました。

概要

作ったもの

画像に対してエフェクトを宣言的に適用できるライブラリを実装し、それを使ったデモアプリを作成しました。

ImageProcessorDemo.gif

以下のように書くことができ、isMirroredbrightnessが変わると、自動的に画像が再生成されます。

ImageProcessingContent(
    selectedImageBytes,
    width = selectedBitmap?.width ?: 0,
    height = selectedBitmap?.height ?: 0,
) {
    if (isMirrored) Mirror()
    if (isBlurred) Blur(radius = { 12 })
    Brightness(
        brightness = { brightness },
    )
}

アーキテクチャ

全体構造

ImageProcessingContent
└─ Composition   
    └─ ImageApplier
        └─ ProcessorNode (root)          
            ├─ ProcessorNode (Mirror)           
            ├─ ProcessorNode (Blur)           
            └─ ProcessorNode (Brightness)

処理の流れ

入力画像   
↓ 
Mirror 反転された画像   
↓ 
Blur ぼかされた画像   
↓ 
Brightness 明るさ調整された画像 (出力)

自動再処理の仕組み

brightness が変わる
↓
Brightness() が再コンポーズされる
↓
ツリーが更新される
↓
再コンポーズ完了を検知して画像処理を実行
↓
新しい画像を生成

実装

基本構造

主に実装したのは以下になります。

基本となるコンポーネント

  • ProcessorNode:ノードツリーを構成する基本単位
  • ImageApplier:Composableからノードツリーを構築、更新する
  • ImageProcessing:システム全体を統括し、Compose Runtimeの管理やルートノードの保持、リソース管理等を行う

実際に画像処理を行うプロセッサ

  • MirrorProcessor:画像を水平方向に反転させる処理
  • BlurProcessor:スタックブラーアルゴリズムを使用したぼかし処理
  • BrightnessProcessor:画像の明るさを調整する処理

公開API

  • ImageProcessingContent():画像処理のエントリーポイントとなるComposable
  • Mirror():MirrorProcessorをラップするComposable
  • Blur(radius: () -> Int):BlurProcessorをラップするComposable
  • Brightness(brightness: () -> Int):BrightnessProcessorをラップするComposable

問題: パラメータが変わっても画像が変わらない

一通り実装し終えてから動作確認をすると、ミラーやぼかしをONにしたり、明るさを変えても画像が変化しませんでした。
デバッグログを見ると、Mirror()Brightness()はちゃんと呼ばれていましたが、ツリー構造の再コンポーズがうまくいってないようでした。

解消法

画像が変わらない問題について、2点修正を行いました。

1. 状態の監視

Composable内で、ラムダを読み取ってからプロセッサに渡すようにしました。

// 元の実装
@Composable
public fun Brightness(
    brightness: () -> Int,
    width: () -> Int,
    height: () -> Int,
) {
    ImageProcessorBlock(
        content = {},
        imageProcessor = BrightnessProcessor(
            brightness = brightness,
            width = width,
            height = height
        )
    )
}

// 変更後
@Composable
public fun Brightness(
    brightness: () -> Int,
    width: () -> Int,
    height: () -> Int,
) {
    // ここで値を取得する
    val brightnessValue = brightness()
    val widthValue = width()
    val heightValue = height()

    ImageProcessorBlock(
        content = {},
        imageProcessor = BrightnessProcessor(
            brightness = { brightnessValue },
            width = { widthValue },
            height = { heightValue }
        )
    )
}

元の実装だと、ラムダ自体は変わらないため値の変更を検知できませんでしたが、コンポーザブル内で値を取得してからプロセッサに渡すことで、値の変更を追跡できるようになります。

2. 再コンポーズの検知

1を試したところ、ツリー構造は更新されるようになりましたが、画像はまだ変わらない状態でした。
再コンポーズの検知ができておらず、画像の処理が走っていないようでした。
そこで、ImageProcessingContentを以下のように修正しました。

// 元の実装
@Composable
public fun ImageProcessingContent(
    inputData: ByteArray?,
    width: Int,
    height: Int,
    content: @Composable () -> Unit
): ByteArray? {
    ...

    LaunchedEffect(inputData) {
        delay(100)  // 再コンポーズ完了を待つ
        if (inputData != null) {
            outputData = imageProcessing.rootNode.process(inputData)
        } else {
            outputData = null
        }
    }
    ...
    return outputData
}

// 変更後
@Composable
public fun ImageProcessingContent(
    inputData: ByteArray?,
    width: Int,
    height: Int,
    content: @Composable () -> Unit
): ByteArray? {
    ...
    // SideEffectに変更
    SideEffect {
        if (inputData != null) {
            outputData = imageProcessing.rootNode.process(inputData)
        } else {
            outputData = null
        }
    }
    ...
    return outputData
}

SideEffect再コンポーズが成功した後に実行されるCompose APIです。
元のLaunchedEffect + delayの方法だと、再コンポーズが完了するか不確実なうえに、明るさやぼかしのON/OFFを検知できない状態でした。
SideEffectを使うことで、パラメータが変わったら自動で実行され、実行タイミングも保証されるようになりました。

追加対応:画像サイズをコンテキストとして取得

各Composableで画像の幅と高さが必要ですが、毎回値を渡すのは冗長だなと思いました。
そこで、CompositionLocalを使って、画像サイズをコンテキストとして共有することにしました。

// 定義
internal data class ImageContext(
    val width: Int,
    val height: Int
)
internal val LocalImageContext = compositionLocalOf<ImageContext?> { null }

@Composable
public fun ImageProcessingContent(
    inputData: ByteArray?,
    width: Int,
    height: Int,
    content: @Composable () -> Unit
): ByteArray? {
    // 生成
    val imageContext = remember(width, height) { ImageContext(width, height) }
    imageProcessing.setContent {
        CompositionLocalProvider(LocalImageContext provides imageContext) {
            // この中でImageContextが使用できる
            content()
        }
    }
    ...
}

// 各Composableで取得して使用(例:Mirror())
@Composable
public fun Mirror() {
    val context = LocalImageContext.current ?: error("ImageContext not provided")

    ImageProcesserBlock(
        content = {},
        imageProcessor = MirrorProcessor(
            width = { context.width },
            height = { context.height }
        )
    )
}

このようにすることで、冒頭に記載したようにAPIがシンプルに使用できるようになりました。

まとめ

Jetpack Compose Runtime APIを使用して宣言的に画像処理を実装してみました。
想像以上にハマりポイントがあり苦戦しましたが、Compose Runtimeの変更検知の仕組みやSideEffectの使い所など、学びも多かったです。 カンファレンスで学んだことを実際に手を動かして試すと理解が深まりますね。
みなさんもぜひセッションを見て、手を動かしてみてください!

明日の レコチョク Advent Calendar 2025 は3日目「【Android】Navigation3のScene/SceneStrategyを用いて端末横幅に応じて表示方法を変えるアプリを作る」です。お楽しみに!

参考

chiaki.kyui

目次