この記事はand factory.inc Advent Calendar 2025 9日目の記事です。
前回は@MatsuNaoPenさんのGASで作ったWebアプリを社内ツールとして共有する時のメリデメとTipsでした!
導入
個人開発でJetPack Glanceでウィジェットを作成した際にGlideImageがない!?!?これじゃURLを渡して画像を表示できない!って状況に陥ったので今回はオンライン上にある画像をウィジェット上に表示する方法をご紹介します
Jetpack Glanceとは
Jetpack Glance は、Jetpack Compose ランタイム上に構築されたフレームワークで、Kotlin API を使用してアプリ ウィジェットを開発、設計できます。アプリ ウィジェットとは、他のアプリに埋め込んで定期的に更新を取得することができる小さなアプリビューです。
今回やること
Glanceにはデフォルトでオンライン上の画像を取得して表示するためのメソッドはないため、画像をデバイスに一旦取り込んでそれを表示する必要があります。
また、Glanceの導入〜ウィジェットの表示まではいろんな方が紹介されていたので今回は省こうと思います🙇
今回は表示したウィジェットにオンライン上の画像を表示する方法を紹介します。
準備
今回実装するにあたって使用する環境は以下のとおりです
kotlin: 2.0.0
glance: 1.1.1
workManager: 2.11.0
hilt-work: 1.3.0
また以下の天気予報APIをお借りしました。このAPIのお天気アイコンを表示することを目的にします。
https://weather.tsukumijima.net/
まずはウィジェットを表示するところまで実装しました
// 使用するのがcomposeでなくglanceなのは注意してください
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import androidx.glance.text.Text
class MyAppWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// create your AppWidget here
MyContent()
}
}
@Composable
private fun MyContent() {
Column(
modifier = GlanceModifier.fillMaxSize().background(Color.White),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "ここに画像を置きます", modifier = GlanceModifier.padding(12.dp))
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="40dp"
android:minHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:maxResizeWidth="250dp"
android:maxResizeHeight="250dp"
android:previewLayout="@layout/my_app_widget_preview">
</appwidget-provider>
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
class MyAppWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MyAppWidget()
}
実装
画像キャッシュ
画像のキャッシュ処理から準備しました。
ダウンロードした画像をメモリにキャッシュする仕組みを実装しました。SVG対応、画像の最適化、キャッシュサイズ管理(上限10MB)などの機能を持たせています。
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import coil.ImageLoader
import coil.decode.SvgDecoder
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
// ウィジェット用の天気アイコン画像キャッシュ
// ネットワークから画像をダウンロードしてメモリ効率よく管理する
object WeatherImageCache {
private const val TAG = "WeatherImageCache"
// スレッドセーフなキャッシュマップ
private val imageCache = ConcurrentHashMap<String, Bitmap>()
// キャッシュサイズの上限(MB)
private const val MAX_CACHE_SIZE_MB = 10
private const val BYTES_IN_MB = 1024 * 1024
//天気アイコンを取得する
// キャッシュから取得、なければダウンロードしてキャッシュに保存
suspend fun getWeatherIcon(context: Context, iconUrl: String): Bitmap? {
return withContext(Dispatchers.IO) {
try {
// キャッシュから取得を試行
imageCache[iconUrl]?.let { cachedBitmap ->
return@withContext cachedBitmap
}
// キャッシュにない場合はダウンロード
downloadAndCacheIcon(context, iconUrl)
} catch (e: Exception) {
null
}
}
}
// 画像をダウンロードしてキャッシュに保存(SVG対応)
private suspend fun downloadAndCacheIcon(context: Context, iconUrl: String): Bitmap? {
return withContext(Dispatchers.IO) {
try {
// Coil ImageLoaderを作成(SVG対応)
val imageLoader = ImageLoader.Builder(context)
.components {
add(SvgDecoder.Factory())
}
.build()
// ImageRequestを作成
val request = ImageRequest.Builder(context)
.data(iconUrl)
.allowHardware(false) // ソフトウェアビットマップを強制
.build()
// 画像を読み込み
val drawable = imageLoader.execute(request).drawable
if (drawable != null) {
// DrawableをBitmapに変換
val originalBitmap = when (drawable) {
is BitmapDrawable -> drawable.bitmap
else -> {
// VectorDrawableやSVGの場合
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth.takeIf { it > 0 } ?: 128,
drawable.intrinsicHeight.takeIf { it > 0 } ?: 128,
Bitmap.Config.ARGB_8888
)
val canvas = android.graphics.Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
bitmap
}
}
// ウィジェット用にサイズを最適化(64x64dp相当)
val optimizedBitmap = optimizeBitmapForWidget(originalBitmap)
// キャッシュサイズをチェックして追加
if (canAddToCache(optimizedBitmap)) {
imageCache[iconUrl] = optimizedBitmap
} else {
clearCache()
imageCache[iconUrl] = optimizedBitmap
}
// 元のビットマップが異なる場合は解放
if (originalBitmap != optimizedBitmap && !originalBitmap.isRecycled) {
originalBitmap.recycle()
}
return@withContext optimizedBitmap
} else {
// nop
}
} catch (e: Exception) {
// nop
}
null
}
}
// ウィジェット用にビットマップを最適化
// メモリ使用量を抑えるためサイズとフォーマットを調整
private fun optimizeBitmapForWidget(originalBitmap: Bitmap): Bitmap {
val targetSize = 128 // 128px (約64dp on mdpi)
// 既に適切なサイズの場合はそのまま返す
if (originalBitmap.width <= targetSize && originalBitmap.height <= targetSize) {
return originalBitmap
}
// アスペクト比を保持してリサイズ
val scale = minOf(
targetSize.toFloat() / originalBitmap.width,
targetSize.toFloat() / originalBitmap.height
)
val scaledWidth = (originalBitmap.width * scale).toInt()
val scaledHeight = (originalBitmap.height * scale).toInt()
return Bitmap.createScaledBitmap(
originalBitmap,
scaledWidth,
scaledHeight,
true
)
}
// キャッシュに追加可能かチェック
private fun canAddToCache(bitmap: Bitmap): Boolean {
val currentCacheSize = getCurrentCacheSizeBytes()
val bitmapSize = getBitmapSizeBytes(bitmap)
return (currentCacheSize + bitmapSize) <= (MAX_CACHE_SIZE_MB * BYTES_IN_MB)
}
// 現在のキャッシュサイズを取得(バイト)
private fun getCurrentCacheSizeBytes(): Long {
return imageCache.values.sumOf { getBitmapSizeBytes(it) }
}
// ビットマップのサイズを取得(バイト)
private fun getBitmapSizeBytes(bitmap: Bitmap): Long {
return bitmap.byteCount.toLong()
}
// キャッシュをクリア
fun clearCache() {
imageCache.values.forEach { bitmap ->
bitmap.recycle()
}
imageCache.clear()
}
// 特定のURLのキャッシュを削除
fun removeFromCache(iconUrl: String) {
imageCache.remove(iconUrl)?.let { bitmap ->
bitmap.recycle()
}
}
// キャッシュサイズの情報を取得
fun getCacheInfo(): String {
val cacheSize = getCurrentCacheSizeBytes()
val cacheSizeMB = cacheSize.toFloat() / BYTES_IN_MB
return "Cache entries: ${imageCache.size}, Size: ${"%.2f".format(cacheSizeMB)} MB"
}
}
UI部分
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.size
import com.example.widgetontechnicalfriday.R
class MyAppWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// create your AppWidget here
MyContent()
}
}
@Composable
private fun MyContent() {
// 現在の状態からPreferencesを取得
val pref = currentState<Preferences>()
// アイコンURLを取得
val iconPath = pref[stringPreferencesKey("weather_icon_path")]
Column(
modifier = GlanceModifier.fillMaxSize().background(Color.White),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally
) {
iconPath?.let { path ->
val bitmap = android.graphics.BitmapFactory.decodeFile(path)
bitmap?.let {
Image(
provider = ImageProvider(it),
contentDescription = "Weather Icon",
modifier = GlanceModifier.size(48.dp)
)
}
} ?: run {
androidx.glance.text.Text("No icon")
}
}
}
}
更新タスク
ここではWorkManagerを利用して更新タスク上でAPIを取得してUI側に渡していきます
import android.content.Context
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.example.widgetontechnicalfriday.transform
import com.example.widgetontechnicalfriday.usecase.GetHomeWeatherInformationUseCase
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@HiltWorker
class WeatherWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val getHomeWeatherInformationUseCase: GetHomeWeatherInformationUseCase,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val _uiState = MutableStateFlow(WidgetUiState())
val uiState = _uiState.asStateFlow()
// 街は今回は固定で検証
val cityId = 400040
_uiState.update { it.copy(isLoading = true) }
val resultHomeResponse = getHomeWeatherInformationUseCase.execute(cityId)
resultHomeResponse.onSuccess { response ->
_uiState.update {
it.copy(
isLoading = false,
viewData = response.transform(),
)
}
}.onFailure { error ->
_uiState.update {
it.copy(
isLoading = false,
)
}
}
// 天気アイコンURLを取得
val iconUrl = uiState.value.viewData?.forecasts?.firstOrNull()?.image?.url
// 画像をダウンロードしてファイルに保存
var iconFilePath: String? = null
iconUrl?.let { url: String ->
try {
val bitmap = WeatherImageCache.getWeatherIcon(applicationContext, url)
bitmap?.let {
// 内部ストレージに画像を保存
val file = java.io.File(applicationContext.filesDir, "weather_icon.png")
java.io.FileOutputStream(file).use { out ->
it.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
}
iconFilePath = file.absolutePath
}
} catch (e: Exception) {
// nop
}
}
val glanceIds = GlanceAppWidgetManager(applicationContext)
.getGlanceIds(MyAppWidget::class.java)
glanceIds.forEach { glanceId ->
updateAppWidgetState(applicationContext, glanceId) { prefs ->
// 画像ファイルパスを保存(URLではなく)
iconFilePath?.let { path ->
prefs[stringPreferencesKey("weather_icon_path")] = path
}
}
MyAppWidget().update(applicationContext, glanceId)
}
return Result.success()
}
}
結果以下のように取得できました🙌
まとめ
Jetpack GlanceではURLから直接画像を表示できないため、画像をダウンロードして端末に保存し、そのファイルパスをウィジェットへ渡す必要があります。
今回はCoilを使った画像取得、メモリキャッシュ、内部ストレージへの保存を組み合わせることで、オンライン画像を Glance ウィジェットに表示する仕組みを実現しました。
この方法を使えば、天気アイコン以外でもさまざまなオンライン画像を Glance で扱えるようになります。

