Androidアプリ開発の覚書き

Androidアプリ開発を、Kotlin, Jetpack Composeで行っています。気になったことをChatGPTに聞いて記事にしています。

Canvas上の動くオブジェクトを実装

Composable関数内のmutableState()でラップしたデータクラス等で定義したそのオブジェクトを、データを変更したオブジェクトで丸ごと置き換えることで、再描画に関連するComposable関数の更新処理をトリガーさせるという手順を取るようです。

ゲームループは、Composable関数内に、再実行されずに一度だけ実行されるLaunchedEffectスコープ内に定義して、その中で、時間経過に応じた値の変化を実現すると良いようです。

フレームワークの挙動の理解をしていなければ、このような処理を思い付くのは難しいですね・・。

ChatGPTに聞かなければ、僕には絶対思い付きませんでした。

ちゃんと「できる」方は、こういったことを、フレームワークのソースコードを直接読んで理解しているんやろうなあと思いました。

本職で業務アプリケーションを作っていた頃も、フレームワークのソースコードを直接読める方がいないと、開発自体が進まないということが時折ありました。プログラミングは奥深いですね・・。

Jetpack ComposeのCanvas上に、次のような描画を行うコードの作成をお願いします。

User
Androidアプリ開発をKotlinとJetpack Composeで行っています。

Jetpack ComposeのCanvas上に、次のような描画を行うコードの作成をお願いします。

1.8 x 8のマス上に、主人公のキャラと、敵キャラを数体表示する。
2.キャラクターそれぞれには、進行方向で見え方の変わる外見がある。
3.敵キャラは勝手に動く
4.敵キャラは、主人公が振る剣が当たると消滅する。

ChatGPT
Jetpack ComposeでCanvasを使用してゲームのような描画を行う場合、以下の概要でコードを作成できます。この例では、基本的な描画方法と、主人公キャラクターと敵キャラクターの描画、そして簡易的な動きや相互作用を実装します。完全なゲームロジックの実装は複雑であり、ここでは基本的なフレームワークを示します。

基本セットアップ
主人公キャラクターと敵キャラクターのモデル定義: 主人公と敵キャラを表すデータクラスを作成します。進行方向も含めます。

ゲーム状態の管理: キャラクターの位置、進行方向、生存状態などを管理する状態を定義します。

描画: Canvasコンポーネントを使用して、ゲームの状態に基づいてキャラクターを描画します。

入力処理: ユーザーの入力(例えば、主人公の移動や剣を振るアクション)を処理します。

敵キャラの自動移動: 敵キャラが自動で動くロジックを定義します。

衝突検出: 主人公の剣と敵キャラの衝突を検出して、敵を消滅させる処理を実装します。

サンプルコードの骨格
以下のコードは上記の概要を基にした簡易的な実装の骨格を示しています。実際には、各キャラクターの描画方法や、動き、衝突検出のロジックを具体的に実装する必要があります。

import androidx.compose.foundation.Canvas  
import androidx.compose.runtime.*  
import androidx.compose.ui.Modifier  
import androidx.compose.ui.geometry.Offset  
import androidx.compose.ui.graphics.Color  
import androidx.compose.ui.graphics.drawscope.DrawScope  
import kotlin.random.Random  
  
@Composable  
fun GameView() {  
  val canvasSize = remember { mutableStateOf(Offset.Zero) }  
  val hero = remember { Hero(Offset(canvasSize.value.width / 2, canvasSize.value.height / 2), Direction.RIGHT) }  
  val enemies = remember { mutableStateListOf<Enemy>() }  
    
  // 初期敵キャラの配置  
  LaunchedEffect(Unit) {  
    for (i in 1..5) { // 5体の敵キャラを生成  
      enemies.add(Enemy(Offset(Random.nextFloat() * canvasSize.value.width, Random.nextFloat() * canvasSize.value.height), Direction.values().random()))  
    }  
  }  
    
  // キャンバスの描画  
  Canvas(modifier = Modifier.fillMaxSize()) {  
    canvasSize.value = size  
      
    // 主人公の描画  
    drawHero(hero)  
  
    // 敵キャラの描画  
    enemies.forEach { enemy ->  
      drawEnemy(enemy)  
    }  
      
    // 敵キャラの移動と衝突検出  
    enemies.forEach { enemy ->  
      moveEnemy(enemy)  
      if (isCollided(hero, enemy)) {  
        enemies.remove(enemy)  
      }  
    }  
  }  
}  
  
// 主人公の描画  
fun DrawScope.drawHero(hero: Hero) {  
  // 主人公の描画ロジック  
}  
  
// 敵キャラの描画  
fun DrawScope.drawEnemy(enemy: Enemy) {  
  // 敵キャラの描画ロジック  
}  
  
// 敵キャラの移動  
fun moveEnemy(enemy: Enemy) {  
  // 敵キャラの移動ロジック  
}  
  
// 衝突検出  
fun isCollided(hero: Hero, enemy: Enemy): Boolean {  
  // 衝突検出ロジック  
  return false  
}  

DrawScopeというのは何ですか?

User
DrawScopeというのは何ですか?

ChatGPT
DrawScopeはJetpack ComposeのCanvas APIの一部で、Canvas上に描画を行う際に使用されるコンテキストです。DrawScopeを使うことで、色、形、テキストなど様々なグラフィック要素をCanvas上に描画することができます。具体的には、線を引いたり、図形を描いたり、画像を表示したりする際に、DrawScopeのメソッドを使用します。

DrawScopeは以下のような特徴があります:

位置とサイズ: DrawScopeは、描画が行われるCanvasのサイズや位置に関する情報を提供します。これにより、Canvasのどの部分に何を描画するかを正確に指定できます。

描画メソッド: 線を引く(drawLine)、円を描く(drawCircle)、矩形を描く(drawRect)、パスに沿って描画する(drawPath)、テキストを描画する(drawText)など、様々な描画メソッドを提供します。

スタイル設定: 描画する際の色(color)、線の太さ(strokeWidth)、塗りつぶしのスタイル(fillStyle)など、グラフィックの見た目をカスタマイズするためのスタイル設定が可能です。

例えば、Canvasコンポーネント内でdrawCircleメソッドを使用して円を描く場合、以下のようにDrawScopeを使います:

Canvas(modifier = Modifier.size(200.dp)) {  
  // DrawScopeのメソッドdrawCircleを使用して、Canvas上に円を描画  
  drawCircle(  
    color = Color.Blue, // 円の色  
    radius = 50f, // 円の半径  
    center = Offset(x = size.width / 2, y = size.height / 2) // 円の中心位置  
  )  
}  

このようにDrawScopeを利用することで、Jetpack Composeにおけるカスタムグラフィックの描画が可能になります。

先ほどの例で、drawHiro()内でも、同様のメソッドが使用できるのですか? 呼び出し元のCanvasとの紐付けは、自動で行ってくれるのですか?

User
先ほどの例で、drawHiro()内でも、同様のメソッドが使用できるのですか?

呼び出し元のCanvasとの紐付けは、自動で行ってくれるのですか?

drawHiro()の実装例を教えてください。

ChatGPT
はい、drawHero()内でもDrawScopeのメソッドを使用して描画を行うことができます。この関数はCanvasコンポーネントから呼び出されるため、DrawScopeとの紐付けは自動的に行われます。つまり、Canvasの描画コンテキストがdrawHero()関数に引き継がれ、そのコンテキスト内で定義されたメソッドを直接使用することが可能です。

drawHero()の実装では、例えば主人公キャラクターを単純な円として表現し、その方向に応じて異なる色を使用することができます。以下に実装例を示します。

// 主人公キャラクターを表すデータクラス  
data class Hero(val position: Offset, val direction: Direction)  
  
// 方向を表す列挙型  
enum class Direction {  
  UP, DOWN, LEFT, RIGHT  
}  
  
// 主人公の描画関数  
fun DrawScope.drawHero(hero: Hero) {  
  // 主人公の色を方向によって変更  
  val color = when (hero.direction) {  
    Direction.UP -> Color.Red  
    Direction.DOWN -> Color.Green  
    Direction.LEFT -> Color.Blue  
    Direction.RIGHT -> Color.Yellow  
  }  
    
  // 主人公を表す円を描画  
  drawCircle(  
    color = color, // 色  
    radius = 20f, // 半径  
    center = hero.position // 主人公の位置  
  )  
    
  // 方向を示すための追加の描画も可能です。例えば、方向を示す小さな円や矢印などを描画することで、  
  // 主人公の向いている方向を視覚的に表示することができます。  
}  

この例では、Heroクラスにposition(位置)とdirection(方向)という2つのプロパティがあります。drawHero()関数では、これらのプロパティに基づいて、主人公を表す円を特定の色で描画しています。drawCircleメソッドはDrawScopeによって提供されるため、Canvasコンポーネント内でdrawHero()が呼び出されると、その描画コンテキスト内で直接使用することができます。

内部的にはどのようなことが行われているのですか?

User
内部的にはどのようなことが行われているのですか?

ChatGPT
Jetpack ComposeのCanvasコンポーネントとDrawScopeを使用した描画プロセスでは、内部的にいくつかの重要なステップが行われています。これらのステップを理解することで、Composeの描画システムの動作原理をより深く理解することができます。

1.描画コンテキストの設定
Canvasコンポーネントがレンダリングされると、Composeは描画を行うためのコンテキストを準備します。このコンテキストはDrawScopeオブジェクトを通じて提供され、描画のためのAPI(線の描画、形の塗りつぶし、テキストの描画など)へのアクセスを可能にします。

2.DrawScopeの利用
DrawScopeは、描画操作を実行するためのメソッド(drawRect, drawCircle, drawPathなど)を提供します。これらのメソッドは、実際の描画命令を低レベルのグラフィックスシステム(AndroidのCanvas APIやSkiaグラフィックスライブラリなど)に変換します。

3.描画命令の変換と実行
drawHeroのようなカスタム描画関数からDrawScopeのメソッドを呼び出すと、内部的には以下のプロセスが行われます:

命令の生成: 関数から呼び出されたDrawScopeのメソッドは、具体的な描画命令(例:特定の位置に円を描画する)を生成します。

命令の変換: 生成された描画命令は、実際に画面上に描画を行うための下層グラフィックスAPIの形式に変換されます。

命令の実行: 変換された描画命令は、グラフィックスレンダリングエンジンによって実行され、結果として画面上に描画が行われます。

4.レンダリングの最適化
Jetpack Composeでは、描画性能を最適化するために、必要な時にのみ描画が行われます。例えば、状態が変化してUIの一部が再描画が必要になった場合にのみ、その部分が再レンダリングされます。これにより、無駄なリソースの消費を抑え、アプリケーションのパフォーマンスを向上させることができます。

5.ユーザー入力との連携
ユーザー入力(タップやスワイプなど)に応じてUIを動的に更新する場合、mutableStateOfやrememberなどのComposeの状態保持メカニズムを使用して、描画する内容を動的に変更できます。これにより、インタラクティブなUIを容易に実装することが可能になります。

このように、CanvasとDrawScopeを使用した描画プロセスは、Composeの宣言的UIパラダイムと密接に統合されており、開発者が直感的にグラフィックスを制御し、高性能なアプリケーションを作成することを支援します。

以上のような描画処理と実際のゲームループ、ユーザー側からの操作はない具体的にどのように連携させるのですか?

User
ありがとうございます。

以上のような描画処理と実際のゲームループ、ユーザー側からの操作はない具体的にどのように連携させるのですか?

ChatGPT
Jetpack Composeでゲームやインタラクティブなアプリケーションを作成する際には、描画処理とゲームループ、ユーザー操作を効果的に連携させることが重要です。以下に、具体的な連携方法を示します。

ゲームループの実装
ゲームループは、ゲームの状態を更新し、画面を定期的に再描画するための中心的なループです。Composeでは、LaunchedEffectとCoroutineを使用してゲームループを実装することができます。

LaunchedEffect(key1 = true) {  
  while (isActive) { // Coroutineがアクティブな間、ループを続ける  
    withFrameNanos { frameTime ->  
      // ゲームの状態更新ロジック  
      // frameTimeを使用してフレームレートに依存しない更新を行う  
    }  
    // ゲームの再描画をトリガーするための状態更新  
  }  
}  

ユーザー操作の処理
ユーザーからの操作(タッチ操作、スワイプなど)は、Composeのイベントリスナーを使用して処理します。例えば、Modifier.pointerInputを使用してタッチイベントを処理することができます。

Canvas(modifier = Modifier  
  .fillMaxSize()  
  .pointerInput(Unit) {  
    detectTapGestures(  
      onPress = { /* タップ時の処理 */ },  
      onDoubleTap = { /* ダブルタップ時の処理 */ },  
      onLongPress = { /* 長押し時の処理 */ }  
    )  
  }) {  
  // Canvasの描画処理  
}  

状態の管理
Composeでは、状態管理のためにmutableStateOfやrememberを使用します。ゲームの状態(キャラクターの位置、得点など)は、これらの状態変数に保持し、ユーザー操作やゲームループによって状態が更新されると自動的にUIが再描画されます。

var score by remember { mutableStateOf(0) } // 得点の状態  

描画と状態の連携
ゲームのロジック(ユーザー操作の処理、ゲームループ)によって更新された状態を基に、Canvas内で描画処理を行います。状態が変化すると、Composeの再構築システムによって関連するUI部分が自動的に更新されます。

これらの要素を組み合わせることで、状態の変化に応じた描画更新、ユーザー入力の処理、ゲームループによる継続的な状態更新を実現し、インタラクティブなアプリケーションやゲームをComposeで作成することができます。

LaunchedEffectというのは何ですか?

User
LaunchedEffectというのは何ですか?

ChatGPT
LaunchedEffectは、Jetpack Composeでサイドエフェクト(副作用)を扱うためのコンポーザブル関数です。サイドエフェクトとは、コンポジションの一部ではないが、UIの表示や状態に影響を与える可能性がある操作のことを指します。これには、データのフェッチ、サブスクリプションの設定、タイマーの使用、その他の非同期操作などが含まれます。

LaunchedEffectは、コンポーザブル関数内でコルーチンを起動するために特に設計されています。これを利用することで、非同期処理を行いつつ、その処理がコンポーザブルのライフサイクルに適切に結びつけられるようになります。

基本的な使用方法
LaunchedEffectは、指定されたキーが変更されるたびにコルーチンを再起動します。キーが変更されない限り、同じコルーチンが継続して実行されます。以下に基本的な使用例を示します。

LaunchedEffect(key1 = someKey) {  
  // ここに非同期処理を記述  
}  

この例では、someKeyが変更されるたびに、LaunchedEffectブロック内のコルーチンが再起動されます。someKeyは、この効果が依存するデータや状態を表すことができます。

ゲームループの実装例
ゲームやアニメーションなどでゲームループを実装する際には、LaunchedEffectを使用して無限ループを作成し、UIの更新をトリガーすることができます。

LaunchedEffect(key1 = true) {  
  while (true) {  
    // ここでゲームの状態を更新  
    delay(16L) // 次のフレームまで少し待機(約60FPS)  
  }  
}  

このコードスニペットでは、LaunchedEffectを使用してゲームの状態を定期的に更新する無限ループを作成しています。key1 = trueとしているのは、このループをコンポーザブルがリコンポーズされるたびに再起動させないためです。delay(16L)は、フレームレートを約60FPSに保つために使用されます。

注意点
LaunchedEffect内で起動されたコルーチンは、コンポーザブルがリコンポーズされてもキャンセルされません。ただし、コンポーザブルが画面から削除されると、自動的にキャンセルされます。

重要なサイドエフェクト(例:データフェッチ、リソースのクリーンアップ)にLaunchedEffectを適切に使用することで、リソースのリークを防ぎ、アプリのパフォーマンスを向上させることができます。

描画更新処理をトリガーするような値の変更を行う処理の実装例をお願いします。

User
描画更新処理をトリガーするような値の変更を行う処理の実装例をお願いします。

ChatGPT
描画更新処理をトリガーするためには、Composeの状態管理システムを利用して、UIの再描画を引き起こすような値の変更を行います。以下は、簡単なアニメーションを例に、描画更新処理をトリガーする値の変更を行う実装例です。

この例では、LaunchedEffectとrememberを使用して、円が画面を横切るアニメーションを作成します。位置情報を保持する状態変数を更新することで、Canvas上の描画位置を変更し、アニメーション効果を実現します。

import androidx.compose.foundation.Canvas  
import androidx.compose.foundation.layout.fillMaxSize  
import androidx.compose.runtime.Composable  
import androidx.compose.runtime.getValue  
import androidx.compose.runtime.mutableStateOf  
import androidx.compose.runtime.remember  
import androidx.compose.runtime.setValue  
import androidx.compose.runtime.LaunchedEffect  
import androidx.compose.ui.Modifier  
import androidx.compose.ui.graphics.Color  
import kotlinx.coroutines.delay  
  
@Composable  
fun MovingCircle() {  
  var position by remember { mutableStateOf(0f) } // 円のX座標位置を保持する状態変数  
  
  // 画面更新をトリガーするためのLaunchedEffect  
  LaunchedEffect(key1 = true) {  
    while (true) {  
      position += 5f // 位置を更新  
      if (position > 1000f) {  
        position = 0f // 画面の端に達したら位置をリセット  
      }  
      delay(16L) // 次のフレームまで少し待機(約60FPS)  
    }  
  }  
  
  // 描画処理  
  Canvas(modifier = Modifier.fillMaxSize()) {  
    drawCircle(  
      color = Color.Red,  
      radius = 50f,  
      center = androidx.compose.ui.geometry.Offset(position, 100f) // Y座標は固定、X座標を変動させる  
    )  
  }  
}  

このコードでは、position変数がアニメーションによって連続的に更新されます。positionの値が変わるたびに、CanvasのdrawCircleメソッドが再評価され、新しい位置に円が描画されます。LaunchedEffectを使用して無限ループ内でpositionを更新し、delay(16L)でフレームレートをコントロールしています。これにより、円が画面を横切る滑らかなアニメーションが実現されます。

この実装例は、描画更新をトリガーするための基本的な方法を示しています。実際のアプリケーションでは、アニメーションの複雑さや性能要件に応じて、より高度なテクニックが必要になる場合があります。

HeroやEnemyに持たせる値については、どこで定義してどこで保持し、どこで更新することになりますか?

User
ありがとうございます。

HeroやEnemyに持たせる値については、どこで定義してどこで保持し、どこで更新することになりますか?

ChatGPT
HeroやEnemyなどのゲーム内エンティティに持たせる値(位置、方向、生存状態など)の管理は、Jetpack Composeを使用している場合、以下のように行うことが一般的です。

値の定義
まず、HeroやEnemyを表すデータクラスを定義します。これらのクラスには、各エンティティの状態を表すプロパティが含まれます。

data class Hero(var position: Offset, var direction: Direction)  
  
data class Enemy(var position: Offset, var direction: Direction)  
  
enum class Direction {  
  UP, DOWN, LEFT, RIGHT  
}  

値の保持
エンティティの現在の状態を保持するためには、コンポーザブル関数内で状態変数を使用します。Jetpack Composeでは、rememberやmutableStateOfを使って状態を保持します。これらの状態変数は、エンティティのプロパティが更新されるたびにUIが再描画されるようにします。

@Composable  
fun GameScreen() {  
  var hero by remember { mutableStateOf(Hero(Offset(0f, 0f), Direction.RIGHT)) }  
  var enemies by remember { mutableStateOf(listOf(Enemy(Offset(100f, 100f), Direction.LEFT))) }  
}  

値の更新
エンティティの状態は、ユーザー入力(例えば、画面タッチやスワイプ)、ゲームループ(例えば、敵の自動移動)、またはその他のイベント(例えば、敵との衝突による消滅)に応じて更新されます。これらの更新は、コンポーザブル関数内またはその外部で行われることがありますが、UIの再描画をトリガーするためには、状態変数を更新する必要があります。

// ユーザー操作によるHeroの移動  
fun moveHero(newDirection: Direction) {  
  // Heroの位置や方向を更新  
  hero = hero.copy(position = hero.position + someOffset, direction = newDirection)  
}  
  
// ゲームループやイベントによるEnemyの更新  
LaunchedEffect(key1 = true) {  
  while(true) {  
    // ここで敵の位置を更新  
    enemies = enemies.map { enemy ->  
      // 敵の新しい位置を計算  
      enemy.copy(position = enemy.position + someOffset)  
    }  
    // フレーム更新間隔  
    delay(16L)  
  }  
}  

このように、HeroやEnemyの状態は、コンポーザブル関数内で定義された状態変数によって保持され、各種イベントやアクションに応じて更新されます。Jetpack Composeの状態管理システムを利用することで、エンティティの状態変化に伴うUIの動的な再描画が可能になります。

dataクラスで定義したオブジェクト自体を、mutableStateとして定義するのですね。

User
ありがとうございます。dataクラスで定義したオブジェクト自体を、mutableStateとして定義するのですね。

そして、その変更は、リファレンスの参照先を丸ごと入れ替えることで実装しているということでよろしいですか?

ChatGPT
はい、その理解で正しいです。

Jetpack Composeでは、UIの再描画をトリガーするために、状態の変更が必要です。dataクラスで定義されたオブジェクトをmutableStateOfでラップすることで、そのオブジェクトが保持する状態が変更された際にUIの再描画をトリガーすることができます。

そして、状態(この場合はHeroやEnemyオブジェクト)を更新する際には、実際にはオブジェクト自体の参照を新しいものに入れ替えます。dataクラスのインスタンスは不変(immutable)であることが多く、プロパティを直接変更することはできません。そのため、copyメソッド(dataクラスで自動的に提供される)を使用して新しいインスタンスを生成し、変更したいプロパティだけを新しい値で指定します。そして、この新しいインスタンスをmutableStateOfで保持している変数に割り当てることで、状態の更新とUIの再描画をトリガーします。

// 状態の更新例  
hero = hero.copy(position = newPosition, direction = newDirection)  

このように参照を更新することで、Composeの再描画システムが状態の変更を検出し、関連するUIコンポーネントを適切に再描画します。このアプローチは、Composeの宣言的UIパラダイムにおいて、状態管理とUIの同期を保つための効果的な方法です。

Heroのデータオブジェクトの同一性をequals()などで定義して、プロパティの値が変更されたことをどこかで監視するというような方法は取るべきではないですか?

User
Heroのデータオブジェクトの同一性をequals()などで定義して、プロパティの値が変更されたことをどこかで監視するというような方法は取るべきではないですか?

ChatGPT
Jetpack Composeにおける状態の管理とUIの更新に関して、equalsやhashCodeを使ってオブジェクトの同一性や等価性を監視する方法は、通常必要ありません。Composeの状態管理システムは、mutableStateOfを使用した状態の変更を自動的に検出し、それに応じてUIを再描画します。このシステムは、状態が変更された際に再描画をトリガーするために、状態オブジェクトの参照の変更を利用します。

Composeの状態管理の原理
Composeの状態管理では、mutableStateOfによって管理される状態が変更されると、その状態を使用しているComposable関数が再実行(再描画)されます。これは、状態の変更が参照の変更によって検出されるため、オブジェクトのequalsやhashCodeメソッドをオーバーライドして等価性をチェックする必要がないことを意味します。

等価性チェックの役割
equalsとhashCodeのオーバーライドは、コレクション内でオブジェクトの等価性を判定する場合や、オブジェクトが特定の条件下で他のオブジェクトと「等しい」とみなされるべき場合に重要です。しかし、ComposeのUI更新の文脈では、オブジェクトのプロパティが変更されたかどうかを監視するためにこれらを使用することは一般的ではありません。Composeは、状態の変更(参照の変更)を検出してUIを更新する責務を担います。

ベストプラクティス
ComposeでUIの更新を管理する場合、以下のようなアプローチが推奨されます:

状態はmutableStateOfでラップし、その状態を更新することでUIの再描画をトリガーします。

オブジェクトのプロパティが変更される場合は、新しいインスタンスを作成して更新します(dataクラスのcopyメソッドを活用)。

UIの更新が必要なプロパティの変更を検出するためにequalsやhashCodeを使用するのではなく、状態の参照変更を通じてComposeに再描画を促します。

このように、Composeは宣言的UIパラダイムにおいて、状態の変更とUIの更新を効率的に連携させるメカニズムを提供します。したがって、状態の変更を監視するためにequalsやhashCodeに依存するよりも、Composeの状態管理の仕組みを利用することが推奨されます。

User
ありがとうございました。おかげですっきり理解できました!

ChatGPT
喜んでお手伝いできて光栄です!理解できてよかったです。もし他に質問がある場合や、さらに詳しい説明が必要な場合は、いつでもお知らせください。Jetpack Composeやその他の開発に関することでお手伝いできることがあれば、お気軽にご質問ください。幸運を祈ります!