3つの画像読み込みライブラリ Glide / Picasso / Coil - JetpackCompose 対応の状況

どれが、今、旬なのか。

まずはリンクを列挙しておきます。

全部使ってみようと思います。

giide vs picasso vs coil

👉 android glide, android picasso, android coil - 調べる - Google トレンド hatena-bookmark

 

Glide


32.8k stars
Watchers 1.1k watching
Forks 6k forks

👉 bumptech/glide: An image loading and caching library for Android focused on smooth scrolling hatena-bookmark

Glide

👉 Glide v4 : Fast and efficient image loading for Android hatena-bookmark

@sjudd Hey, I believe you're one of the maintainers of Glide. Is it a planned feature? Does Glide's team want help from the community on this? Jetpack Compose is going to stable soon, I believe later this month or next month

👉 Jetpack Compose Support · Issue #4459 · bumptech/glide hatena-bookmark

 

Picasso


18.3k stars
Watchers 867 watching
Forks 4k forks

👉 square/picasso: A powerful image downloading and caching library for Android hatena-bookmark

Picasso

👉 Picasso hatena-bookmark

Nobody works on Picasso. If you want something in the next N years definitely use Coil. Or Glide. Or whatever. They're all fine.

Image loading is a terrible, horrible business to be in. It's been really nice not being in that business for the last few years. I don't see a reason to resume. I certainly have no intent to support it anymore. Picasso accomplished its goal of moving the ecosystem out of the painful image loaders of 2011/2012 to the fluent and extensible ones we know today. But it's filled with technical debt and the legacy of poor design (at least, in hindsight) and is currently very, very stuck between a major refactor and redesign with no end in sight.

👉 Consider providing Jetpack Compose support · Issue #2203 · square/picasso hatena-bookmark

 

Coil


8.4k stars
Watchers 98 watching
Forks 512 forks

👉 coil-kt/coil: Image loading for Android backed by Kotlin Coroutines. hatena-bookmark

Coil

👉 Coil hatena-bookmark

To add support for Jetpack Compose, import the extension library:

implementation("io.coil-kt:coil-compose:2.1.0")

👉 Jetpack Compose - Coil hatena-bookmark

 

まとめ

JetpackCompose への対応ライブラリ群も見逃せません。

👉 skydoves/landscapist: 🍂 Jetpack Compose image loading library that fetches and displays network images with Glide, Coil, and Fresco hatena-bookmark

👉 wasabeef/composable-images: The Composable Images is a library providing Jetpack Compose wrapper for Glide, Picasso, and Coil. hatena-bookmark

アーキテクチャー、フレームワーク、ライブラリの選定はそのプロダクトの安定感に直結します。

機能確認を中心に検証しながら、将来の本筋を外してはなりません。

👉 画像読み込みライブラリ「COIL」 hatena-bookmark


「Compose Compiler Reports」 recompose される条件とタイミングと範囲を知りたい

サンプル として、

Screen-level と その中に Button 3つの composable と ViewModel でカウンターを作ります。


@HiltViewModel
class HomeViewModel @Inject constructor() : ViewModel() {

  private val _countState = mutableStateOf(0)
  val countState: State<Int> = _countState

  private val _countStateFlow = MutableStateFlow(0)
  val countStateFlow = _countStateFlow.asStateFlow()

  fun incState() {
    _countState.value += 1
  }

  fun incStateFlow() {
    _countStateFlow.value += 1
  }

}


@Composable
fun HomeScreen(
  viewModel: HomeViewModel = hiltViewModel()
) {

  Timber.d("### HomeScreen composed.")

  var countA by remember { mutableStateOf(0) }
  val countB by viewModel.countState
  val countC by viewModel.countStateFlow.collectAsStateWithLifecycle()

  val onClickA = { countA += 1 }
  val onClickB = { viewModel.incState() }
  val onClickC = { viewModel.incStateFlow() }


  Text("${System.currentTimeMillis()}")

  ButtonA(countA, onClickA)

  Divider()

  ButtonB(countB, onClickB)

  Divider()

  ButtonC(countC, onClickC)

}

@Composable
private fun ButtonA(count: Int, onClick: () -> Unit) {

  Timber.d("### ButtonA(${count}, ${onClick.hashCode()}) composed.")

  Button(onClick = onClick) {
    Text("A: $count")
  }

}

@Composable
private fun ButtonB(count: Int, onClick: () -> Unit) {

  Timber.d("### ButtonB(${count}, ${onClick.hashCode()}) composed.")

  Button(onClick = onClick) {
    Text("B: $count")
  }

}

@Composable
private fun ButtonC(count: Int, onClick: () -> Unit) {

  Timber.d("### ButtonC(${count}, ${onClick.hashCode()}) composed.")

  Button(onClick = onClick) {
    Text("C: $count")
  }

}

ボタンを順番に押していきます。

挙動とログを予想できますか。

 

■ 結果

クリックのたびに、

Screen が recompose されています。

クリックしてないボタンも recompose されていますね!

Recompose Highlighter でもやってみました。



そうなるんですか。

なんでかな。

 

■ 「Compose Compiler Reports」 を使う

ツールを使って書き出してみます。

The Compose Compiler plugin can generate reports / metrics around certain compose-specific concepts that can be useful to understand what is happening with some of your compose code at a fine-grained level.

👉 androidx/compiler-metrics.md at androidx-main · androidx/androidx hatena-bookmark


// build.gradle

kotlinOptions {
  if (project.findProperty("composeCompilerReports") == "true") {
    freeCompilerArgs += [
        "-P",
        "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
            project.buildDir.absolutePath + "/compose_compiler"
    ]
  }
  if (project.findProperty("composeCompilerMetrics") == "true") {
    freeCompilerArgs += [
        "-P",
        "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
            project.buildDir.absolutePath + "/compose_compiler"
    ]
  }
}


./gradlew assembleRelease -PcomposeCompilerReports=true


./gradlew assembleRelease -PcomposeCompilerMetrics=true

👉 Jetpack Compose Stability Explained | by Ben Trengrove | Android Developers | Jul, 2022 | Medium hatena-bookmark

Compose Compiler Reports


// app_release-composables.txt

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HomeScreen(
  unstable viewModel: HomeViewModel? = @dynamic hiltViewModel(null, $composer, 0, 0b0001)
)

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ButtonA(
  stable count: Int
  stable onClick: Function0<Unit>
)

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ButtonB(
  stable count: Int
  stable onClick: Function0<Unit>
)

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ButtonC(
  stable count: Int
  stable onClick: Function0<Unit>
)

ViewModel が untable で @dynamic です。


// app_release-classes.txt

unstable class HomeViewModel {
  stable val _countState: MutableState<Int>
  stable val countState: State<Int>
  unstable val _countStateFlow: MutableStateFlow<Int>
  unstable val countStateFlow: StateFlow<Int>
  <runtime stability> = Unstable
}

Flow が unstable なので ViewModel として unstable となっているように見えます。


// app_release-composables.csv

name,composable,skippable,restartable,readonly,inline,isLambda,hasDefaults,defaultsGroup,groups,calls,
HomeScreen,1,1,1,0,0,0,1,0,2,2,
ButtonA,1,1,1,0,0,0,0,0,1,1,
ButtonB,1,1,1,0,0,0,0,0,1,1,
ButtonC,1,1,1,0,0,0,0,0,1,1,

Button たちは、すべて同じ数字。

Screen はそれらと違う。

なんとく、ViewModel が原因のような雰囲気。

しかし、気安く ViewModel に @Stable とかつけたくないし。

 

■ remember を使う

onClick* のラムダが毎回初期化されているというので、


// val onClickB = { viewModel.incState() }
// val onClickC = { viewModel.incStateFlow() }

val onClickB = remember { { viewModel.incState() } }
val onClickC = remember { { viewModel.incStateFlow() } }

とすべて remember 扱いにします。

イメージしていたログ出力になりました。

 

■ 「::」を使う

こんな記述はどう? というので書き換えてみます。


// val onClickB = { viewModel.incState() }
// val onClickC = { viewModel.incStateFlow() }

// val onClickB = remember { { viewModel.incState() } }
// val onClickC = remember { { viewModel.incStateFlow() } }

val onClickB = viewModel::incState
val onClickC = viewModel::incStateFlow

これもイメージしていたログ出力になりました。

少し驚きました。

 

■ State 取得部分はどうなの?

Screen 自体が recompose されてるのなら、こうしたほうがいいのでは?


// val countB by viewModel.countState
// val countC by viewModel.countStateFlow.collectAsStateWithLifecycle()

val countB by remember { viewModel.countState }
val countC by remember { viewModel.countStateFlow }.collectAsStateWithLifecycle()

...

もう、知らん...

(ここは後日検証します。)

 

■ まとめ

そもそも recompose の条件とタイミングをコード目視で把握できますかね。


「考えないほうがいい、きちんと行儀よくコードを書け」的な記事もよく見かけます。

しかし、それで SideEffect系 きちんと使える気がしません。

では、だれか decompose をお願いします。

👉 パフォーマンスに任せてコードの見通しを優先するほうが「recompose沼」にはまらなくていいのではという逆説。 - Qiita hatena-bookmark

👉 takahirom/decomposer: Gradle Plugin that allows you to decompile bytecode compiled with Jetpack Compose Compiler Plugin into Java and check it hatena-bookmark

👉 【Jetpack Compose】「Layout Inspector Recomposition counts」で re-compose 回数を確認する hatena-bookmark


【Jetpack Compose】Square 製 Radiography で View の構造を確認する

👉 square/radiography: Text-ray goggles for your Android UI. hatena-bookmark


dependencies {
  implementation 'androidx.compose.ui:ui-tooling:1.0.0-betaXY'
  implementation 'com.squareup.radiography:radiography:2.4.1'
}

scan() すると、画面のツリー構造がテキストで取得できるようでです。


DecorView { 1080×2160px }
├─LinearLayout { id:main, 1080×1962px }
│ ├─EditText { id:username, 580×124px, focused, text-length:0, ime-target }
│ ├─EditText { id:password, 580×124px, text-length:0 }
│ ╰─LinearLayout { 635×154px }
│   ├─Button { id:signin, 205×132px, text-length:7 }
│   ╰─Button { id:forgot_password, 430×132px, text-length:15 }
├─View { id:navigationBarBackground, 1080×132px }
╰─View { id:statusBarBackground, 1080×66px }


// Render the view hierarchy for all windows.
val prettyHierarchy = Radiography.scan()


// Extension function on View, renders starting from that view.
val prettyHierarchy = someView.scan()

 

Jetpack Compose でやってみる

この画面でやってみます。

square / radiography

Composable にコードを追加しておきます。


@Composable
fun HomeScreen(
  viewModel: HomeViewModel = hiltViewModel()
) {

  // ...

  val view: View = LocalView.current

  SideEffect {
    println(Radiography.scan())
    println(view.scan())
  }

}

👉 SideEffect - Compose における副作用  |  Jetpack Compose  |  Android Developers hatena-bookmark

 

結果


I: window-focus:false
I:  DecorView { 1080×2160px }
I:  ├─LinearLayout { 1080×2116px }
I:  │ ├─ViewStub { id:action_mode_bar_stub, GONE, 0×0px }
I:  │ ╰─FrameLayout { id:content, 1080×2039px }
I:  │   ╰─ComposeView { 1080×2039px }
I:  │     ╰─AndroidComposeView { 1080×2039px }
I:  │       ╰─CompositionLocalProvider { 1080×2039px }
I:  │         ╰─ScaffoldLayout { 1080×2039px }
I:  │           ├─<subcomposition of ScaffoldLayout>
I:  │           ├─<subcomposition of ScaffoldLayout>
I:  │           │ ╰─SnackbarHost
I:  │           ├─<subcomposition of ScaffoldLayout>
I:  │           ├─<subcomposition of ScaffoldLayout>
I:  │           │ ╰─CompositionLocalProvider { 1080×154px }
I:  │           │   ├─NavBottomBar { 1080×154px }
I:  │           │   │ ╰─Row { 1080×154px, SELECTABLE-GROUP }
I:  │           │   │   ├─BottomNavigationItem { 180×154px, roll:Tab, SELECTED }
I:  │           │   │   │ ╰─BottomNavigationTransition { 162×154px }
I:  │           │   │   │   ├─Box { 66×66px, layout-id:"icon" }
I:  │           │   │   │   │ ├─Icon
I:  │           │   │   │   │ │ ╰─RenderVectorGroup
I:  │           │   │   │   │ ╰─Icon { 66×66px }
I:  │           │   │   │   ╰─Box { 96×44px, layout-id:"label" }
I:  │           │   │   │     ╰─ProvideTextStyle { 96×44px, text-length:4 }
I:  │           │   │   ├─BottomNavigationItem { 180×154px, roll:Tab, SELECTED }
I:  │           │   │   │ ╰─BottomNavigationTransition { 151×154px }
I:  │           │   │   │   ├─Box { 66×66px, layout-id:"icon" }
I:  │           │   │   │   │ ├─Icon
I:  │           │   │   │   │ │ ╰─RenderVectorGroup
I:  │           │   │   │   │ ╰─Icon { 66×66px }
I:  │           │   │   │   ╰─Box { 85×44px, layout-id:"label" }
I:  │           │   │   │     ╰─ProvideTextStyle { 85×44px, text-length:5 }
I:  │           │   │   ├─BottomNavigationItem { 180×154px, roll:Tab, SELECTED }
I:  │           │   │   │ ╰─BottomNavigationTransition { 156×154px }
I:  │           │   │   │   ├─Box { 66×66px, layout-id:"icon" }
I:  │           │   │   │   │ ├─Icon
I:  │           │   │   │   │ │ ├─RenderVectorGroup
I:  │           │   │   │   │ │ ├─RenderVectorGroup
I:  │           │   │   │   │ │ ├─RenderVectorGroup
I:  │           │   │   │   │ │ ╰─RenderVectorGroup
I:  │           │   │   │   │ ╰─Icon { 66×66px }
I:  │           │   │   │   ╰─Box { 90×44px, layout-id:"label" }
I:  │           │   │   │     ╰─ProvideTextStyle { 90×44px, text-length:5 }
I:  │           │   │   ├─BottomNavigationItem { 180×154px, roll:Tab, SELECTED }
I:  │           │   │   │ ╰─BottomNavigationTransition { 150×154px }
I:  │           │   │   │   ├─Box { 66×66px, layout-id:"icon" }
I:  │           │   │   │   │ ├─Icon
I:  │           │   │   │   │ │ ╰─RenderVectorGroup
I:  │           │   │   │   │ ╰─Icon { 66×66px }
I:  │           │   │   │   ╰─Box { 84×44px, layout-id:"label" }
I:  │           │   │   │     ╰─ProvideTextStyle { 84×44px, text-length:4 }
I:  │           │   │   ├─BottomNavigationItem { 180×154px, roll:Tab, SELECTED }
I:  │           │   │   │ ╰─BottomNavigationTransition { 146×154px }
I:  │           │   │   │   ├─Box { 66×66px, layout-id:"icon" }
I:  │           │   │   │   │ ├─Icon
I:  │           │   │   │   │ │ ╰─RenderVectorGroup
I:  │           │   │   │   │ ╰─Icon { 66×66px }
I:  │           │   │   │   ╰─Box { 80×44px, layout-id:"label" }
I:  │           │   │   │     ╰─ProvideTextStyle { 80×44px, text-length:4 }
I:  │           │   │   ╰─BottomNavigationItem { 180×154px, roll:Tab, SELECTED }
I:  │           │   │     ╰─BottomNavigationTransition { 180×154px }
I:  │           │   │       ├─Box { 66×66px, layout-id:"icon" }
I:  │           │   │       │ ├─Icon
I:  │           │   │       │ │ ╰─RenderVectorGroup
I:  │           │   │       │ ╰─Icon { 66×66px }
I:  │           │   │       ╰─Box { 114×44px, layout-id:"label" }
I:  │           │   │         ╰─ProvideTextStyle { 114×44px, text-length:8 }
I:  │           │   ╰─AdaptiveAd { 1080×0px }
I:  │           │     ╰─ViewFactoryHolder { 1080×0px }
I:  │           │       ╰─AdView { 1080×0px }
I:  │           │         ╰─FrameLayout { 0×0px }
I:  │           ╰─<subcomposition of ScaffoldLayout>
I:  │             ╰─NavHost
I:  │               ╰─Box
I:  │                 ╰─LocalOwnersProvider
I:  │                   ├─TopBar
I:  │                   │ ╰─CompositionLocalProvider
I:  │                   │   ├─Row
I:  │                   │   │ ╰─CompositionLocalProvider { roll:Button }
I:  │                   │   │   ├─CompositionLocalProvider
I:  │                   │   │   │ ╰─RenderVectorGroup
I:  │                   │   │   ╰─CompositionLocalProvider
I:  │                   │   ├─Row
I:  │                   │   │ ╰─ProvideTextStyle { text-length:4 }
I:  │                   │   ╰─CompositionLocalProvider
I:  │                   ╰─Column
I:  │                     ├─Button { roll:Button }
I:  │                     │ ╰─CompositionLocalProvider
I:  │                     │   ╰─Text { text-length:7 }
I:  │                     ├─Spacer
I:  │                     ├─Button { roll:Button }
I:  │                     │ ╰─CompositionLocalProvider
I:  │                     │   ╰─Text { text-length:7 }
I:  │                     ├─Spacer
I:  │                     ├─Button { roll:Button }
I:  │                     │ ╰─CompositionLocalProvider
I:  │                     │   ╰─Text { text-length:7 }
I:  │                     ├─Spacer
I:  │                     ╰─Button { roll:Button }
I:  │                       ╰─CompositionLocalProvider
I:  │                         ╰─Text { text-length:5 }
I:  ├─View { id:navigationBarBackground, 1080×44px }
I:  ╰─View { id:statusBarBackground, 1080×77px }

I: AndroidComposeView:
I: window-focus:false
I:  AndroidComposeView { 1080×2039px }
I:  ╰─CompositionLocalProvider { 1080×2039px }
I:    ╰─ScaffoldLayout { 1080×2039px }
I:      ├─<subcomposition of ScaffoldLayout>
I:      ├─<subcomposition of ScaffoldLayout>
I:      │ ╰─SnackbarHost
I:      ├─<subcomposition of ScaffoldLayout>
I:      ├─<subcomposition of ScaffoldLayout>
I:      │ ╰─CompositionLocalProvider { 1080×154px }
I:      │   ├─NavBottomBar { 1080×154px }
I:      │   │ ╰─Row { 1080×154px, SELECTABLE-GROUP }
I:      │   │   ├─BottomNavigationItem { 180×154px, roll:Tab, SELECTED }
I:      │   │   │ ╰─BottomNavigationTransition { 162×154px }
I:      │   │   │   ├─Box { 66×66px, layout-id:"icon" }
I:      │   │   │   │ ├─Icon
I:      │   │   │   │ │ ╰─RenderVectorGroup
I:      │   │   │   │ ╰─Icon { 66×66px }
I:      │   │   │   ╰─Box { 96×44px, layout-id:"label" }
I:      │   │   │     ╰─ProvideTextStyle { 96×44px, text-length:4 }
I:      │   │   ├─BottomNavigationItem { 180×154px, roll:Tab, SELECTED }
I:      │   │   │ ╰─BottomNavigationTransition { 151×154px }
I:      │   │   │   ├─Box { 66×66px, layout-id:"icon" }
I:      │   │   │   │ ├─Icon
I:      │   │   │   │ │ ╰─RenderVectorGroup
I:      │   │   │   │ ╰─Icon { 66×66px }
I:      │   │   │   ╰─Box { 85×44px, layout-id:"label" }
I:      │   │   │     ╰─ProvideTextStyle { 85×44px, text-length:5 }
I:      │   │   ├─BottomNavigationItem { 180×154px, roll:Tab, SELECTED }
I:      │   │   │ ╰─BottomNavigationTransition { 156×154px }
I:      │   │   │   ├─Box { 66×66px, layout-id:"icon" }
I:      │   │   │   │ ├─Icon
I:      │   │   │   │ │ ├─RenderVectorGroup
I:      │   │   │   │ │ ├─RenderVectorGroup
I:      │   │   │   │ │ ├─RenderVectorGroup
I:      │   │   │   │ │ ╰─RenderVectorGroup
I:      │   │   │   │ ╰─Icon { 66×66px }
I:      │   │   │   ╰─Box { 90×44px, layout-id:"label" }
I:      │   │   │     ╰─ProvideTextStyle { 90×44px, text-length:5 }
I:      │   │   ├─BottomNavigationItem { 180×154px, roll:Tab, SELECTED }
I:      │   │   │ ╰─BottomNavigationTransition { 150×154px }
I:      │   │   │   ├─Box { 66×66px, layout-id:"icon" }
I:      │   │   │   │ ├─Icon
I:      │   │   │   │ │ ╰─RenderVectorGroup
I:      │   │   │   │ ╰─Icon { 66×66px }
I:      │   │   │   ╰─Box { 84×44px, layout-id:"label" }
I:      │   │   │     ╰─ProvideTextStyle { 84×44px, text-length:4 }
I:      │   │   ├─BottomNavigationItem { 180×154px, roll:Tab, SELECTED }
I:      │   │   │ ╰─BottomNavigationTransition { 146×154px }
I:      │   │   │   ├─Box { 66×66px, layout-id:"icon" }
I:      │   │   │   │ ├─Icon
I:      │   │   │   │ │ ╰─RenderVectorGroup
I:      │   │   │   │ ╰─Icon { 66×66px }
I:      │   │   │   ╰─Box { 80×44px, layout-id:"label" }
I:      │   │   │     ╰─ProvideTextStyle { 80×44px, text-length:4 }
I:      │   │   ╰─BottomNavigationItem { 180×154px, roll:Tab, SELECTED }
I:      │   │     ╰─BottomNavigationTransition { 180×154px }
I:      │   │       ├─Box { 66×66px, layout-id:"icon" }
I:      │   │       │ ├─Icon
I:      │   │       │ │ ╰─RenderVectorGroup
I:      │   │       │ ╰─Icon { 66×66px }
I:      │   │       ╰─Box { 114×44px, layout-id:"label" }
I:      │   │         ╰─ProvideTextStyle { 114×44px, text-length:8 }
I:      │   ╰─AdaptiveAd { 1080×0px }
I:      │     ╰─ViewFactoryHolder { 1080×0px }
I:      │       ╰─AdView { 1080×0px }
I:      │         ╰─FrameLayout { 0×0px }
I:      ╰─<subcomposition of ScaffoldLayout>
I:        ╰─NavHost
I:          ╰─Box
I:            ╰─LocalOwnersProvider
I:              ├─TopBar
I:              │ ╰─CompositionLocalProvider
I:              │   ├─Row
I:              │   │ ╰─CompositionLocalProvider { roll:Button }
I:              │   │   ├─CompositionLocalProvider
I:              │   │   │ ╰─RenderVectorGroup
I:              │   │   ╰─CompositionLocalProvider
I:              │   ├─Row
I:              │   │ ╰─ProvideTextStyle { text-length:4 }
I:              │   ╰─CompositionLocalProvider
I:              ╰─Column
I:                ├─Button { roll:Button }
I:                │ ╰─CompositionLocalProvider
I:                │   ╰─Text { text-length:7 }
I:                ├─Spacer
I:                ├─Button { roll:Button }
I:                │ ╰─CompositionLocalProvider
I:                │   ╰─Text { text-length:7 }
I:                ├─Spacer
I:                ├─Button { roll:Button }
I:                │ ╰─CompositionLocalProvider
I:                │   ╰─Text { text-length:7 }
I:                ├─Spacer
I:                ╰─Button { roll:Button }
I:                  ╰─CompositionLocalProvider
I:                    ╰─Text { text-length:5 }

どう使います?、これ。

scan() 時のオプションもいろいろあるようなので試してみますか。