こんにちは。CL Android開発チームの吉兼 (@blackbracken) です。

私たちのプロダクトでは、2024年3月ごろにウィジェット機能をリリースしました。
現段階でアプリの全画面のうち7割以上がJetpack Composeによって記述されており、同じくComposeを使ってウィジェットのUIが作れるJetpack Glanceは魅力的で、ウィジェットの機能開発に採用するに至りました。

ウィジェット機能では、ウィジェット自体がアプリ内でテーマとしてカスタマイズ可能な仕様になっており、いくつかの技術的課題に当たりました。
この記事ではリリースから半年経過した現在、そんなJetpack Glanceを活用したウィジェット機能の実装過程で対応した課題とそれらへのアプローチについてお話しします。

アプリ側とウィジェット内の状態を同期する

はじめに、前提としてCLのウィジェットの仕様についてお話しします。
CLのウィジェット機能では、まずアプリケーション側でテーマを定義し、ウィジェットの配置時にそのテーマを選択することでデザインを決定します。
このテーマはウィジェット配置後にも編集・削除可能なため、アプリケーション側にあるテーマへの参照をウィジェットが持ち、それを元に状態を作る必要があります。

ウィジェットとアプリケーション側のテーマの関係図

Widgetの状態を表現する

Jetpack Glanceでは、GlanceStateDefinition を用いることでウィジェット側に持たせる状態と、その保存方法を定義できます。

CLのウィジェットでは、その状態に含まれるプロパティはそれぞれ、特定の順序で更新されていくとは限りません。テーマを読み込むために、状態に含まれた themeId からRoom経由で読み込む、ネットワークからデータを取得するなど、いくつかのケースが考えられます。

そのため、状態の更新ごとに更新後の状態は表示可能かを確かめる必要がありました。

ウィジェットの状態の更新タイミング

これらを型で表現し、そのインスタンスを GlanceStateDefinition へそのまま保存するために、状態は sealed interface として定義します。

例として、バッテリーを表示するウィジェットの状態は以下のように定義されます。


// CLで使われるWidgetState共通の定義
interface WidgetState {
  val widgetId: String? // 対応するテーマ
}

// 後述する状態の保存のためにSerializableにする
@Serializable
sealed interface BatteryWidgetState : WidgetState {
  val backgroundImageUri: String? // ネットワーク経由で定期更新
  val artistId: String? // テーマ設定からの読み込み
  val percentage: Int? // BroadcastReceiver経由で不定期に更新
  val isCharging: Boolean?  // BroadcastReceiver経由で不定期に更新
  
  @Serializable  
  data class Loading(  
    override val widgetId: String? = null,  
    override val backgroundImageUri: String? = null,  
    override val artistId: String? = null,  
    override val percentage: Int? = null,  
    override val isCharging: Boolean? = null,  
    // ... その他中間で必要なプロパティ
  ) : BatteryWidgetState {

    // ユーティリティ
    fun toShownOrLoading(): BatteryWidgetState = if (  
      widgetId != null &&  
      backgroundImageUri != null &&  
      artistId != null &&  
      isCharging != null &&  
      percentage != null  
    ) {  
      Shown(widgetId, backgroundImageUri, artistId, percentage, isCharging)  
    } else {  
      this  
    }  
  }  

  // 表示可能な状態
  // T <: T? なので、全てのプロパティをnon-nullに落とせる
  @Serializable  
  data class Shown(  
    override val widgetId: String,  
    override val backgroundImageUri: String,  
    override val artistId: String,  
    override val percentage: Int,  
    override val isCharging: Boolean,  
  ) : BatteryWidgetState  

  // 対応するwidgetIdのテーマが削除されていたときの状態
  @Serializable  
  data object Deleted : BatteryWidgetState {  
    override val widgetId: String? = null  
    override val backgroundImageUri: String? = null  
    override val artistId: String? = null  
    override val percentage: Int? = null  
    override val isCharging: Boolean? = null  
  }  
}

これにより、ウィジェットの状態を型付けて表現することができました。

Widgetの状態を保存する

さて、ウィジェットに持たせる状態のモデルを表現できました。
次に、そのまま型を保持しながらシリアライズ/デシリアライズを行うために、kotlinx.serializationを用いて下記のような TypedGlanceStateDefinition<T>を定義します。Tには、KSerializerを持つことを前提にウィジェットの種類ごとのStateの型が入ります。


class TypedGlanceStateDefinition<T : Any> private constructor(  
  private val dataStorePrefix: String,  
  private val initialState: T,  
  private val serializationStrategy: SerializationStrategy<T>,  
  private val deserializationStrategy: DeserializationStrategy<T>,  
) : GlanceStateDefinition<T> {  
  
  constructor(  
    dataStorePrefix: String,  
    initialState: T,  
    kSerializer: KSerializer<T>,  
  ) : this(dataStorePrefix, initialState, kSerializer, kSerializer)  
  
  override suspend fun getDataStore(context: Context, fileKey: String): DataStore<T> {  
    return DataStoreFactory.create(  
      serializer = WidgetStateSerializer(  
        initialState,  
        serializationStrategy,  
        deserializationStrategy,  
      ),  
      produceFile = { getLocation(context, fileKey) },  
    )  
  }  
  
  override fun getLocation(context: Context, fileKey: String): File =  
    context.dataStoreFile(dataSourceFileName(fileKey))  
  
  private fun dataSourceFileName(fileKey: String) = "$dataStorePrefix-$fileKey"  
}  
  
private class WidgetStateSerializer<T : Any>(  
  override val defaultValue: T,  
  private val serializationStrategy: SerializationStrategy<T>,  
  private val deserializationStrategy: DeserializationStrategy<T>,  
) : Serializer<T> {  
  
  private val json = Json {  
    ignoreUnknownKeys = true  
  }  
  
  override suspend fun readFrom(input: InputStream): T = try {  
    json.decodeFromString(  
      deserializationStrategy,  
      input.readBytes().decodeToString(),  
    )  
  } catch (exception: SerializationException) {  
    throw CorruptionException("Could not read widget state: ${exception.message}")  
  }  
  
  override suspend fun writeTo(written: T, output: OutputStream) {  
    output.use {  
      it.write(  
        json.encodeToString(serializationStrategy, written).encodeToByteArray(),  
      )  
    }  
  }  
}

これを、それぞれのWidgetのGlanceAppWidgetReceiverのcompanion objectとして定義することで、ウィジェット各所で作成・更新が行えるようになりました。

ウィジェットの状態を更新する

最後に、TypedGlanceStateDefinitionを使ったウィジェットの状態の更新についてです。

Glanceでは、 GlanceAppWidgetManager から該当するウィジェットの GlanceId を取得し、GlanceAppWidgetに定義された拡張関数であるsuspend GlanceAppWidget.updateAll(Context) などを使って状態を更新します。
cf. Manage and update GlanceAppWidget | Android Developers

ただし、上記のメソッドをただ直接呼び出すだけでは直和型にしている恩恵を大きく得られません。状態の制約については要件によって異なりますが、CLのウィジェットでは以下のような制約が課されます。

  • GlanceAppWidgetの種類ごとにWidgetStateの型は同じになる
  • テーマが削除されているウィジェットは更新しない
  • widgetIdがnullであれば未初期化、存在すれば初期化済みであると扱う

これらを強制させながら簡単に行うために、下記のようなメソッドを定義しました。


internal suspend inline fun <reified GW : GlanceAppWidget, S : WidgetState> updateSameCategoryAllWidgetState(
  context: Context,  
  widget: GW,  
  stateDefinition: TypedGlanceStateDefinition<S>,  
  crossinline checkWidgetDeleted: suspend (S) -> Boolean, // ウィジェットの状態から、このウィジェットが消えているかを返す
  crossinline updateStateInitialized: suspend (S) -> S, // 初期化済のウィジェットの状態を更新して返す  
  crossinline updateStateNotInitialized: suspend (S) -> S, // 未初期化のウィジェットの状態を更新して返す  
) {
  // asyncのためにcoroutine blockを作る
  coroutineScope {
    // 削除されていないウィジェットのみでフィルターし、Idを絞る
    val glanceIdAndStates = GlanceAppWidgetManager(context)  
      .getGlanceIds(GW::class.java)  
      .associateWith { glanceId -> getAppWidgetState(context, stateDefinition, glanceId) }  
      .filter { (_, prevState) -> !checkWidgetDeleted(prevState) }  
      .toMap()  

    // 未初期化のウィジェットを更新した後の状態
    val newStatesNotInitialized = glanceIdAndStates  
      .filter { (_, prevState) -> prevState.widgetId == null }  
      .mapValues { (_, prevState) ->  
        async { updateStateNotInitialized(prevState) }  
      }  
    // 初期化済のウィジェットを更新した後の状態
    val newStatesInitialized = glanceIdAndStates  
      .filter { (_, prevState) -> prevState.widgetId != null }  
      .mapValues { (_, prevState) ->  
        async { updateStateInitialized(prevState) }  
      }

    // 更新済みの状態のMap
    // asyncで並列に初期化しているので Deferred<T> になっている
    val newStates: Map<GlanceId, Deferred<S>> = newStatesNotInitialized + newStatesInitialized  

    // 状態を使ってウィジェットを更新する
    newStates.forEach { (glanceId, newState) ->
      updateAppWidgetState(
        context = context,
        glanceId = glanceId,
        definition = stateDefinition,
        updateState = { newState.await() },
      )
      widget.update(context, glanceId)
    }  
  }
}

上記のように定義されたメソッドを、Widgetを更新したい任意のタイミングで呼び出します。これを経由することで、制約を気にすることなく安全にウィジェットを更新できました。

オンライン上の画像の表示

ウィジェット上にネットワークから取得した画像を表示するには、直接URL先の画像を表示できるわけではなく、デバイス側に一度画像を保存し表示にそれを使う必要があります。
また、ウィジェットのbitmapに持たせられるメモリ量には制限があり、それらを意識しておく必要があります。

The total Bitmap memory used by the RemoteViews object cannot exceed that required to fill the screen 1.5 times, ie. (screen width x screen height x 4 x 1.5) bytes.

これを実現するために、画像読み込みライブラリのcoilを使った下記のようなメソッド群を持ったオブジェクトを考えます。


object LocalImageCache {

  // 画像を書き込んで、書き込み先の絶対パスを返す
  suspend fun write(
    context: Context,
    imageUrl: String,
    fileName: String,
    maxImageSizeInKiloBytes: Int = 1024,
  ): String? {
    val bitmap = loadImageAsBitmap(
      context = context,
      imageUrl = imageUrl,
    ) ?: return null
    val compressedByteArray = tryCompressWithMaxSizeLimit(bitmap, maxImageSizeInKiloBytes)

	return localImageFile(context, fileName)  
      .apply { writeBytes(compressedByteArray) }  
      .absolutePath
  }

  // 画像を読み込んでbitmapにする
  private fun loadImageAsBitmap(context: Context, imageUrl: String, imageLoader: ImageLoader = ImageLoader(context)): Bitmap? {
    // callback -> suspend
    return suspendCancellableCoroutine { cont ->
      imageLoader.enqueue(
        ImageRequest.Builder(context)
          .data(imageUrl)
          .target(
            onSuccess = {
              cont.resume(it.toBitmap())
            },
            onError = {
              cont.resume(it?.toBitmap())
            },
          )
          .build(),
      )
    }
  }

  // bitmapのサイズを指定未満にしてそのByteArrayを返す
  private fun tryCompressWithMaxSizeLimit(bitmap: Bitmap, maxImageSizeInKiloBytes: Int = 1024): ByteArray? {  
    // 100%のクオリティから5%ずつ下げながら試行し, 最初に条件に合うByteArrayを返す  
    return (100 downTo 10 step 5)  
      .firstNotNullOfOrNull { qualify ->  
        val stream = ByteArrayOutputStream()  
        val didCompress = bitmap.compress(Bitmap.CompressFormat.JPEG, qualify, stream)  
        // 画像を圧縮した結果、それが [maxImageSizeInKiloBytes] より小さければ  
        if (didCompress && stream.size() < 1024 * maxImageSizeInKiloBytes) {  
          stream.toByteArray()  
        } else {  
          null  
        }  
      }  
  }

  private fun writeBitmap(context: Context, fileName: String, byteArray: ByteArray): String {  
    return localImageFile(context, fileName)  
      .apply { writeBytes(byteArray) }  
      .absolutePath  
  }

  private fun localImageFile(context: Context, fileName: String): File = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), fileName)
}

このメソッドを画像の読み込み時に利用することでbitmapとしてウィジェットで取り扱うことで、オンライン・オフラインを考慮せずに画像を表示することができました。
実際には、保存した画像は端末に残り続けるため時間経過による削除や、キャッシュ削除による対応などを別途考える必要があります。

ウィジェットを強制的に正方形にする

敢えてウィジェットを正方形に強制したいケースがあります。幸いにも Jetpack Glance 側にはLocalSizeというCompositionLocalが定義されており、それを使うことで簡単に実現できました。


@Composable
fun SquareBox(
  modifier: GlanceModifier = GlanceModifier,
  content: @Composable (Dp) -> Unit,
) {
  val dpSize = LocalSize.current
  val shortSide = min(size.height, size.width)

  Box(
    modifier = modifier.size(shortSide),
    contentAlignment = Alignment.Center,
  ) {
    content(shortSide)
  }
}

さいごに

プロダクトであった制約とアプローチの一部を、簡易化した実例とともにご紹介しました。Jetpack Glanceを使ったウィジェット開発の一助になれば幸いです。

参考