vtuberCameraの更新履歴
5. Jetpack Compose UI移行
Jetpack Composeの導入メリット
現在のXMLベースのUIからJetpack Composeへの移行により、以下の利点が得られます:
主要メリット
- 宣言的UI: UIの状態を直接記述し、より直感的な開発が可能
- コード削減: XMLレイアウトファイルが不要になり、コード量を大幅削減
- リアルタイムプレビュー: Android Studioでの即座の表示確認
- 再利用性: Composable関数による高い再利用性
- 型安全性: Kotlinの型システムによる安全なUI構築
段階的移行戦略
移行アプローチ
- 新機能をComposeで構築: 既存コードを維持しながら新機能をComposeで実装
- 画面単位での移行: 1つの画面ずつ段階的にComposeに移行
- 相互運用性活用: ComposeViewとAndroidViewを使った併用期間
CameraX + Compose統合
1. 依存関係の追加
// app/build.gradle
dependencies {
def compose\_bom \= "2025.05.01"
def camerax\_version \= "1.4.0"
// Compose BOM
implementation platform("androidx.compose:compose-bom:$compose\_bom")
// Compose Core
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.activity:activity-compose:1.9.0'
// Navigation Compose
implementation 'androidx.navigation:navigation-compose:2.7.7'
// ViewModel Compose
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0'
// CameraX Compose統合
implementation "androidx.camera:camera-compose:1.0.0-alpha01"
implementation "androidx.camera:camera-viewfinder-compose:1.4.0"
// Permissions(Compose対応)
implementation "com.google.accompanist:accompanist-permissions:0.32.0"
}
2. Compose対応のカメラ実装
CameraScreen.kt
@Composable
fun CameraScreen(
onNavigateBack: () \-\> Unit,
onImageCaptured: (Uri) \-\> Unit
) {
val context \= LocalContext.current
val lifecycleOwner \= LocalLifecycleOwner.current
// パーミッション管理
val cameraPermissionState \= rememberPermissionState(
permission \= Manifest.permission.CAMERA
)
// CameraX状態管理
var imageCapture by remember { mutableStateOf\<ImageCapture?\>(null) }
var camera by remember { mutableStateOf\<Camera?\>(null) }
var isFlashOn by remember { mutableStateOf(false) }
var lensFacing by remember { mutableStateOf(CameraSelector.LENS\_FACING\_BACK) }
// パーミッション確認
LaunchedEffect(Unit) {
if (\!cameraPermissionState.status.isGranted) {
cameraPermissionState.launchPermissionRequest()
}
}
if (cameraPermissionState.status.isGranted) {
CameraContent(
imageCapture \= imageCapture,
camera \= camera,
isFlashOn \= isFlashOn,
lensFacing \= lensFacing,
onImageCaptureReady \= { imageCapture \= it },
onCameraReady \= { camera \= it },
onFlashToggle \= { isFlashOn \= \!isFlashOn },
onLensFacingChange \= {
lensFacing \= if (lensFacing \== CameraSelector.LENS\_FACING\_BACK) {
CameraSelector.LENS\_FACING\_FRONT
} else {
CameraSelector.LENS\_FACING\_BACK
}
},
onCapturePhoto \= { capturePhoto(imageCapture, context, onImageCaptured) },
onNavigateBack \= onNavigateBack
)
} else {
PermissionDeniedContent(
onRequestPermission \= { cameraPermissionState.launchPermissionRequest() }
)
}
}
@Composable
private fun CameraContent(
imageCapture: ImageCapture?,
camera: Camera?,
isFlashOn: Boolean,
lensFacing: Int,
onImageCaptureReady: (ImageCapture) \-\> Unit,
onCameraReady: (Camera) \-\> Unit,
onFlashToggle: () \-\> Unit,
onLensFacingChange: () \-\> Unit,
onCapturePhoto: () \-\> Unit,
onNavigateBack: () \-\> Unit
) {
val context \= LocalContext.current
val lifecycleOwner \= LocalLifecycleOwner.current
Box(modifier \= Modifier.fillMaxSize()) {
// カメラプレビュー
CameraPreview(
modifier \= Modifier.fillMaxSize(),
onImageCaptureReady \= onImageCaptureReady,
onCameraReady \= onCameraReady,
lensFacing \= lensFacing,
lifecycleOwner \= lifecycleOwner
)
// カメラコントロールUI
CameraControls(
modifier \= Modifier.fillMaxSize(),
isFlashOn \= isFlashOn,
onFlashToggle \= onFlashToggle,
onLensFacingChange \= onLensFacingChange,
onCapturePhoto \= onCapturePhoto,
onNavigateBack \= onNavigateBack
)
}
}
@Composable
private fun CameraPreview(
modifier: Modifier \= Modifier,
onImageCaptureReady: (ImageCapture) \-\> Unit,
onCameraReady: (Camera) \-\> Unit,
lensFacing: Int,
lifecycleOwner: LifecycleOwner
) {
val context \= LocalContext.current
AndroidView(
factory \= { ctx \-\>
PreviewView(ctx).apply {
scaleType \= PreviewView.ScaleType.FILL\_CENTER
}
},
modifier \= modifier,
update \= { previewView \-\>
val cameraProviderFuture \= ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
val cameraProvider \= cameraProviderFuture.get()
val preview \= Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val imageCapture \= ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE\_MODE\_MINIMIZE\_LATENCY)
.build()
val cameraSelector \= CameraSelector.Builder()
.requireLensFacing(lensFacing)
.build()
try {
cameraProvider.unbindAll()
val camera \= cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture
)
onImageCaptureReady(imageCapture)
onCameraReady(camera)
} catch (exc: Exception) {
Log.e("CameraPreview", "Camera binding failed", exc)
}
}, ContextCompat.getMainExecutor(context))
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CameraControls(
modifier: Modifier \= Modifier,
isFlashOn: Boolean,
onFlashToggle: () \-\> Unit,
onLensFacingChange: () \-\> Unit,
onCapturePhoto: () \-\> Unit,
onNavigateBack: () \-\> Unit
) {
Column(
modifier \= modifier.padding(16.dp)
) {
// トップバー
Row(
modifier \= Modifier.fillMaxWidth(),
horizontalArrangement \= Arrangement.SpaceBetween,
verticalAlignment \= Alignment.CenterVertically
) {
IconButton(onClick \= onNavigateBack) {
Icon(
imageVector \= Icons.AutoMirrored.Filled.ArrowBack,
contentDescription \= "戻る",
tint \= Color.White
)
}
IconButton(onClick \= onFlashToggle) {
Icon(
imageVector \= if (isFlashOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff,
contentDescription \= if (isFlashOn) "フラッシュOFF" else "フラッシュON",
tint \= Color.White
)
}
}
Spacer(modifier \= Modifier.weight(1f))
// ボトムコントロール
Row(
modifier \= Modifier.fillMaxWidth(),
horizontalArrangement \= Arrangement.SpaceEvenly,
verticalAlignment \= Alignment.CenterVertically
) {
// カメラ切り替えボタン
IconButton(
onClick \= onLensFacingChange,
modifier \= Modifier.size(60.dp)
) {
Icon(
imageVector \= Icons.Filled.Cameraswitch,
contentDescription \= "カメラ切り替え",
tint \= Color.White,
modifier \= Modifier.size(32.dp)
)
}
// シャッターボタン
FilledTonalButton(
onClick \= onCapturePhoto,
modifier \= Modifier.size(80.dp),
shape \= CircleShape
) {
Icon(
imageVector \= Icons.Filled.CameraAlt,
contentDescription \= "写真撮影",
modifier \= Modifier.size(40.dp)
)
}
// プレースホルダー(ギャラリーアクセス等)
Box(modifier \= Modifier.size(60.dp))
}
}
}
@Composable
private fun PermissionDeniedContent(
onRequestPermission: () \-\> Unit
) {
Column(
modifier \= Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment \= Alignment.CenterHorizontally,
verticalArrangement \= Arrangement.Center
) {
Icon(
imageVector \= Icons.Filled.Camera,
contentDescription \= null,
modifier \= Modifier.size(64.dp),
tint \= MaterialTheme.colorScheme.primary
)
Spacer(modifier \= Modifier.height(16.dp))
Text(
text \= "カメラの使用許可が必要です",
style \= MaterialTheme.typography.headlineSmall,
textAlign \= TextAlign.Center
)
Spacer(modifier \= Modifier.height(8.dp))
Text(
text \= "写真撮影を行うためにカメラへのアクセスを許可してください",
style \= MaterialTheme.typography.bodyMedium,
textAlign \= TextAlign.Center,
color \= MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier \= Modifier.height(24.dp))
Button(onClick \= onRequestPermission) {
Text("許可する")
}
}
}
private fun capturePhoto(
imageCapture: ImageCapture?,
context: Context,
onImageCaptured: (Uri) \-\> Unit
) {
val imageCapture \= imageCapture ?: return
val name \= SimpleDateFormat("yyyyMMddHHmmss", Locale.JAPAN)
.format(System.currentTimeMillis())
val contentValues \= ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY\_NAME, "VtuberCamera\_$name")
put(MediaStore.MediaColumns.MIME\_TYPE, "image/jpeg")
if (Build.VERSION.SDK\_INT \> Build.VERSION\_CODES.P) {
put(MediaStore.Images.Media.RELATIVE\_PATH, "Pictures/VtuberCamera")
}
}
val outputOptions \= ImageCapture.OutputFileOptions.Builder(
context.contentResolver,
MediaStore.Images.Media.EXTERNAL\_CONTENT\_URI,
contentValues
).build()
imageCapture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(context),
object : ImageCapture.OnImageSavedCallback {
override fun onError(exception: ImageCaptureException) {
Log.e("CameraCapture", "Photo capture failed: ${exception.message}", exception)
}
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
output.savedUri?.let { uri \-\>
onImageCaptured(uri)
}
}
}
)
}
3. Navigation Composeの実装
MainActivity.kt(Compose版)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
VtuberCameraTheme {
VtuberCameraApp()
}
}
}
}
@Composable
fun VtuberCameraApp() {
val navController \= rememberNavController()
NavHost(
navController \= navController,
startDestination \= "opening"
) {
composable("opening") {
OpeningScreen(
onNavigateToCamera \= {
navController.navigate("camera") {
popUpTo("opening") { inclusive \= true }
}
}
)
}
composable("camera") {
CameraScreen(
onNavigateBack \= {
navController.popBackStack()
},
onImageCaptured \= { uri \-\>
// 写真撮影後の処理
navController.navigate("preview/$uri")
}
)
}
composable("settings") {
SettingsScreen(
onNavigateBack \= {
navController.popBackStack()
}
)
}
composable(
"preview/{imageUri}",
arguments \= listOf(navArgument("imageUri") { type \= NavType.StringType })
) { backStackEntry \-\>
val imageUri \= backStackEntry.arguments?.getString("imageUri")
imageUri?.let { uri \-\>
ImagePreviewScreen(
imageUri \= Uri.parse(uri),
onNavigateBack \= {
navController.popBackStack()
}
)
}
}
}
}
4. テーマとスタイリング
Theme.kt
@Composable
fun VtuberCameraTheme(
darkTheme: Boolean \= isSystemInDarkTheme(),
content: @Composable () \-\> Unit
) {
val colorScheme \= when {
darkTheme \-\> darkColorScheme(
primary \= Color(0xFF6750A4),
onPrimary \= Color.White,
surface \= Color(0xFF1C1B1F),
onSurface \= Color(0xFFE6E1E5)
)
else \-\> lightColorScheme(
primary \= Color(0xFF6750A4),
onPrimary \= Color.White,
surface \= Color(0xFFFFFBFE),
onSurface \= Color(0xFF1C1B1F)
)
}
MaterialTheme(
colorScheme \= colorScheme,
typography \= Typography,
content \= content
)
}
移行戦略の詳細
フェーズ1: 基盤準備(1週間)
-
Compose依存関係追加
- BOMを使用した統一バージョン管理
- 必要なCompose関連ライブラリ導入
-
テーマシステム構築
- Material Design 3対応
- ダークモード対応
- カスタムカラーパレット定義
フェーズ2: 段階的移行(2-3週間)
-
新画面をComposeで実装
- 設定画面の全面Compose化
- 画像プレビュー画面の新規作成
-
メイン画面の移行
- カメラ画面のCompose化
- CameraXとComposeの統合
- ジェスチャー操作の実装
フェーズ3: ナビゲーション統合(1週間)
-
Navigation Compose導入
- Fragment/Activity間の遷移をCompose Navigationに移行
- 型安全なナビゲーション実装
-
相互運用性の活用
- ComposeViewを使用した段階的移行
- 既存Fragmentとの併用
Composeの高度な機能活用
1. アニメーション
@Composable
fun AnimatedCameraButton(
onClick: () \-\> Unit,
isCapturing: Boolean \= false
) {
val scale by animateFloatAsState(
targetValue \= if (isCapturing) 0.8f else 1f,
animationSpec \= tween(durationMillis \= 100),
label \= "button\_scale"
)
val rotation by animateFloatAsState(
targetValue \= if (isCapturing) 180f else 0f,
animationSpec \= tween(durationMillis \= 300),
label \= "button\_rotation"
)
FilledTonalButton(
onClick \= onClick,
modifier \= Modifier
.size(80.dp)
.scale(scale)
.rotate(rotation),
shape \= CircleShape
) {
Icon(
imageVector \= Icons.Filled.CameraAlt,
contentDescription \= "写真撮影",
modifier \= Modifier.size(40.dp)
)
}
}
2. カスタムComposable
@Composable
fun CameraGridOverlay(
modifier: Modifier \= Modifier,
isVisible: Boolean \= true
) {
if (isVisible) {
Canvas(modifier \= modifier) {
val strokeWidth \= 1.dp.toPx()
val gridColor \= Color.White.copy(alpha \= 0.5f)
// 縦のグリッド線
drawLine(
color \= gridColor,
start \= Offset(size.width / 3, 0f),
end \= Offset(size.width / 3, size.height),
strokeWidth \= strokeWidth
)
drawLine(
color \= gridColor,
start \= Offset(size.width \* 2 / 3, 0f),
end \= Offset(size.width \* 2 / 3, size.height),
strokeWidth \= strokeWidth
)
// 横のグリッド線
drawLine(
color \= gridColor,
start \= Offset(0f, size.height / 3),
end \= Offset(size.width, size.height / 3),
strokeWidth \= strokeWidth
)
drawLine(
color \= gridColor,
start \= Offset(0f, size.height \* 2 / 3),
end \= Offset(size.width, size.height \* 2 / 3),
strokeWidth \= strokeWidth
)
}
}
}
3. タップフォーカス機能
@Composable
fun TapToFocusPreview(
camera: Camera?,
modifier: Modifier \= Modifier
) {
var focusPoint by remember { mutableStateOf\<Offset?\>(null) }
Box(
modifier \= modifier
.pointerInput(camera) {
detectTapGestures { offset \-\>
camera?.let { cam \-\>
val meteringPointFactory \= SurfaceOrientedMeteringPointFactory(
size.width.toFloat(),
size.height.toFloat()
)
val meteringPoint \= meteringPointFactory.createPoint(offset.x, offset.y)
val meteringAction \= FocusMeteringAction.Builder(meteringPoint)
.disableAutoCancel()
.build()
cam.cameraControl.startFocusAndMetering(meteringAction)
focusPoint \= offset
// 2秒後にフォーカスインジケーターを非表示
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
delay(2000)
focusPoint \= null
}
}
}
}
) {
// フォーカスインジケーター
focusPoint?.let { point \-\>
FocusIndicator(
offset \= point,
modifier \= Modifier.fillMaxSize()
)
}
}
}
@Composable
private fun FocusIndicator(
offset: Offset,
modifier: Modifier \= Modifier
) {
val scale by animateFloatAsState(
targetValue \= 1f,
animationSpec \= tween(durationMillis \= 200),
label \= "focus\_scale"
)
Canvas(modifier \= modifier) {
val indicatorSize \= 60.dp.toPx()
val strokeWidth \= 2.dp.toPx()
drawCircle(
color \= Color.White,
radius \= indicatorSize / 2,
center \= offset,
style \= Stroke(width \= strokeWidth),
alpha \= 0.8f
)
}
}
6. 実装ロードマップ(更新版)
フェーズ1: 基盤改善(2-3週間)
-
CameraX導入
- 既存Camera2コードをCameraXに移行
- 基本的な撮影機能の実装
- テスト・デバッグ
-
Android 15対応
- API Level 35への更新
- 新パーミッションシステム対応
- 互換性テスト
-
Compose基盤準備
- Jetpack Compose依存関係の追加
- テーマシステムの構築
- Material Design 3対応
フェーズ2: UI移行開始(2-3週間)
-
段階的Compose移行
- 設定画面のCompose化
- 新しい画像プレビュー画面の実装
- ComposeViewを使用した段階的統合
-
回転対応実装
- OrientationEventListenerの実装
- Compose UIの回転対応
- プレビュー画面の最適化
フェーズ3: メイン機能のCompose化(3-4週間)
-
カメラ画面のCompose移行
- CameraXとComposeの完全統合
- タップフォーカス機能の実装
- カスタムComposableコンポーネント作成
-
Navigation Compose導入
- Fragment/Activityベースからの完全移行
- 型安全なナビゲーション実装
- 画面間アニメーション追加
-
エラーハンドリング強化
- Compose対応の例外処理
- ユーザーフィードバックの改善
- ログシステムの実装
フェーズ4: 高度な機能実装(2-3週間)
-
フェイストラッキング機能
- Google ML Kitの統合
- リアルタイム顔検出
- Composeベースのフィルター・エフェクト
-
UI/UX向上
- 高度なアニメーション実装
- カスタムジェスチャー対応
- アクセシビリティ向上
フェーズ5: 最適化・テスト(1-2週間)
-
パフォーマンス最適化
- Compose Recomposition最適化
- メモリ使用量の削減
- バッテリー消費の最小化
-
総合テスト
- 複数デバイステスト
- パフォーマンステスト
- ユーザビリティテスト
7. 期待される効果
技術的改善
- 開発効率: CameraX導入により開発時間を50%削減
- 互換性: 98%以上のAndroidデバイスでの安定動作
- 保守性: 高レベルAPIによりメンテナンスコストを削減
- コード品質: Jetpack Composeによる宣言的UIで可読性向上
- 型安全性: Kotlinの型システムによる安全な開発環境
ユーザー体験向上
- 使いやすさ: 直感的な回転対応により撮影体験が向上
- 信頼性: エラーハンドリング改善により安定性が向上
- 機能性: 最新Android機能の活用により競争力が向上
- レスポンシブ性: Composeの宣言的UIによる滑らかな操作感
- 視覚的魅力: 高度なアニメーションとエフェクト
開発体験の向上
- リアルタイムプレビュー: Android Studioでの即座の表示確認
- ホットリロード: コード変更の即座反映
- 再利用性: Composable関数による高いコンポーネント再利用
- テスト容易性: UI状態の明確な分離によるテスト効率向上
ビジネス価値
- 市場対応: Google Play要件への準拠
- 将来性: 最新技術スタックによる長期的な競争優位性
- 拡張性: 新機能追加の基盤構築
- 開発コスト削減: 宣言的UIによる開発時間短縮
- メンテナンス性: コードベースの簡潔化によるメンテナンス効率向上
8. まとめ
現在のVtuberCameraプロジェクトは基本的なカメラ機能を提供していますが、以下の包括的な改善により大幅な品質向上が期待できます:
主要改善項目
- CameraX導入: 開発効率と安定性の大幅改善
- Jetpack Compose移行: モダンで保守性の高いUI実装
- 回転対応: ユーザビリティの向上
- Android 15対応: 最新プラットフォームへの準拠と新機能活用
技術的恩恵
- 統一されたアーキテクチャ: CameraX + Composeによる一貫した開発体験
- 将来への対応: 最新のAndroid開発トレンドに沿った実装
- 開発効率: 宣言的UIとCameraXの高レベルAPIによる開発時間短縮
- 品質向上: 型安全性とテスト容易性の向上
戦略的価値
これらの改善により、単なる機能追加を超えて、アプリの技術的負債の解消と長期的な競争力を確保できます。特にJetpack Composeへの移行は、今後のAndroid開発において必須の技術となるため、早期の導入により開発チームのスキル向上も期待できます。
結論: 段階的なアプローチにより、リスクを最小限に抑えながら、より堅牢で使いやすく、将来的な拡張にも対応できる次世代のカメラアプリを構築できます。
プロジェクト変更履歴 - 2025/06/04-05
概要
6/4, 6/5での変更点をまとめます。基本的なカメラアプリの機能が実装され、写真の撮影、プレビュー、保存が可能になりました。また、Material3デザインを採用し、モダンなUIを実現しています。
UIの変更
1. カメラ画面の基本レイアウト
- トップバー: カメラ切り替えボタンを追加
- 中央部: カメラプレビュー表示
- 下部: 撮影ボタン(FloatingActionButton)を配置
2. プレビュー機能の追加
- サムネイル表示: 撮影した写真のサムネイル表示(右下に配置)
- フルスクリーンプレビュー: プレビュー画面での全画面表示
- 操作ボタン: プレビュー画面での「削除」と「戻る」ボタン
3. デザイン要素
- サムネイル: 角丸デザインの採用
- テーマ: Material3のテーマ適用
- アイコン: カメラ切り替え、撮影用アイコンの使用
ロジックの変更
1. カメラ機能
- 初期化: カメラの初期化とプレビュー表示
- 撮影機能: 写真撮影機能の実装
- カメラ切り替え: フロント/バックカメラの切り替え機能
2. 状態管理
CameraViewModel
での状態管理を実装:
- カメラセレクター: カメラの状態管理
- 写真URI: 撮影した写真のURI管理
- プレビューモード: プレビュー表示状態の管理
3. 写真保存機能
- 保存処理: 撮影した写真の保存処理
- ファイル名生成: 日時ベースのファイル名生成
- 保存先指定: Pictures/VTuberCameraフォルダへの保存
ライブラリ等の変更
1. Compose関連
// 追加されたCompose依存関係
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material:material-icons-extended")
- Compose BOM: 2024.02.00の導入
- Material3: 新しいマテリアルデザインの追加
- Foundation: 基本的なCompose機能
- 拡張アイコン: より多くのアイコンセットの追加
2. 画像処理
// 画像読み込み用ライブラリ
implementation("io.coil-kt:coil-compose:2.5.0")
- Coilライブラリ: 画像読み込み用ライブラリの追加
3. カメラ機能
// CameraXライブラリ群
implementation("androidx.camera:camera-core:1.3.1")
implementation("androidx.camera:camera-camera2:1.3.1")
implementation("androidx.camera:camera-lifecycle:1.3.1")
implementation("androidx.camera:camera-view:1.3.1")
implementation("androidx.camera:camera-extensions:1.3.1")
CameraXライブラリの追加:
- camera-core: カメラの基本機能
- camera-camera2: Camera2 APIの実装
- camera-lifecycle: ライフサイクル連携
- camera-view: カメラビュー表示
- camera-extensions: 拡張機能
4. その他の依存関係
- コアAndroid依存関係: 最新バージョンへの更新
- テスト関連: 依存関係の整理と最適化
技術的詳細
アーキテクチャパターン
- MVVM: ViewModelを使用した状態管理
- Jetpack Compose: 宣言的UIフレームワーク
- CameraX: モダンなカメラAPI
主要な実装
- カメラプレビュー: CameraXとComposeの統合
- 写真撮影: ImageCaptureの実装
- 状態管理: StateFlowを使用したリアクティブな状態管理
- ファイル操作: MediaStoreを使用した写真保存
パフォーマンス最適化
- メモリ効率: Coilによる効率的な画像読み込み
- UI応答性: Composeによるスムーズなアニメーション
- カメラ最適化: CameraXによる最適化されたカメラ処理
今後の予定
短期的な改善
- エラーハンドリングの強化
- 権限管理の改善
- UI/UXの細かな調整
中期的な機能追加
- フィルター機能の追加
- 動画撮影機能
- ギャラリー機能の拡張
長期的な目標
- AI機能の統合
- クラウド連携
- より高度な画像編集機能
まとめ
この2日間の開発により、基本的なカメラアプリとしての機能が完成しました。Material3デザインシステムの採用により、モダンで使いやすいUIを実現し、CameraXライブラリの活用により安定したカメラ機能を提供できています。
次の段階では、ユーザー体験の向上とより高度な機能の実装に取り組む予定です。
変更履歴 (2025-06-06)
CameraXの実装と改善
主な変更点
-
CameraXの導入と基本実装
- CameraX 1.4.0への更新
- カメラプレビュー表示の実装
- 写真撮影機能の実装
- フロント/バックカメラ切り替え機能の実装
-
フラッシュ機能の実装
- フラッシュモードの切り替え機能(OFF/ON/AUTO)
- フラッシュモードの状態管理
- UIでのフラッシュモード表示と切り替え
-
ズーム機能の実装
- ズームイン/アウト機能
- カメラの最大ズーム倍率に基づく制限
- ズームコントロールUIの実装
-
UI/UXの改善
- Material3デザインの適用
- カメラプレビュー表示の最適化
- 撮影した写真のプレビュー表示
- サムネイル表示機能
技術的な改善
-
アーキテクチャの改善
- MVVMパターンの採用
- ViewModelでの状態管理
- StateFlowを使用したリアクティブな状態管理
-
エラーハンドリング
- カメラ初期化エラーの処理
- パーミッション管理の改善
- 撮影エラーの処理
-
パフォーマンス最適化
- カメラプレビューの効率的な実装
- メモリリークの防止
- ライフサイクル管理の改善
修正された問題
-
FlashModeの参照エラー
-
ImageCapture.FLASH_MODE_*
定数の正しい参照 - フラッシュモードの状態管理の修正
-
-
型推論の問題
-
collectAsStateWithLifecycle()
の正しい使用 - StateFlowの型定義の修正
-
-
ライフサイクル管理
-
LocalLifecycleOwner
の正しい参照 - コンポーズ可能な関数でのライフサイクル管理の改善
-
今後の課題
-
機能の拡張
- フォーカス制御の実装
- 画像の回転処理の改善
- 動画撮影機能の追加
-
UI/UXの改善
- カメラ設定のカスタマイズ機能
- 撮影モードの追加
- エフェクト機能の実装
-
パフォーマンスの最適化
- メモリ使用量の最適化
- バッテリー消費の改善
- 起動時間の短縮
VTuberCamera カメラ機能改善 変更履歴 (2025/6/7)
概要
カメラのフラッシュライトとカメラ切り替え機能の改善を行いました。主に以下の問題に対処しました:
- フラッシュモードの切り替えが正しく機能しない
- カメラ切り替え時にプレビューが更新されない
- SurfaceProviderの再設定問題
- カメラの状態管理の不備
変更内容
1. カメラ状態管理の改善
// カメラ状態管理の改善
var imageCapture: ImageCapture? by remember { mutableStateOf(null) }
var camera: Camera? by remember { mutableStateOf(null) }
var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) }
var previewView: PreviewView? by remember { mutableStateOf(null) }
var preview: Preview? by remember { mutableStateOf(null) }
- カメラ関連の状態変数を追加し、ライフサイクル管理を改善
- PreviewViewとPreviewの参照を保持することで、プレビューの再作成を防止
2. カメラプレビューの改善
AndroidView(
factory = { ctx ->
PreviewView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
previewView = this // PreviewViewの参照を保存
}
},
modifier = Modifier.fillMaxSize()
) { view ->
// 初回のみカメラプロバイダーを初期化
if (cameraProvider == null) {
// ...
// プレビューを一度だけ作成してSurfaceProviderを設定
preview = Preview.Builder().build().also {
it.setSurfaceProvider(view.surfaceProvider)
}
// ...
}
}
- PreviewViewの参照を保存
- プレビューの作成とSurfaceProviderの設定を一度だけ実行
- カメラプロバイダーの初期化を最適化
3. 状態変更の監視と再バインド機能
LaunchedEffect(cameraSelector, flashMode) {
// カメラプロバイダーとプレビューが準備できている場合のみ再バインド
if (cameraProvider != null && preview != null) {
Log.d("CameraScreen", "Rebinding camera due to state change")
bindCameraWithPreview(
// ...
preview = preview!!, // 既存のプレビューを再利用
// ...
)
}
}
- カメラセレクタとフラッシュモードの変更を監視
- 必要な状態が揃っている場合のみ再バインドを実行
- 既存のプレビューを再利用して安定性を向上
4. カメラバインド関数の改善
private fun bindCameraWithPreview(
// ...
preview: Preview, // 既存のPreviewを受け取る
// ...
): Camera? {
return try {
// ImageCaptureのみ新しく作成(フラッシュモードを反映)
val imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.setFlashMode(flashMode)
.build()
// 既存のバインディングを解除
cameraProvider.unbindAll()
// 既存のPreviewと新しいImageCaptureでバインド
val camera = cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview, // 既存のPreviewを再利用
imageCapture
)
// ...
}
}
- 既存のPreviewを再利用
- ImageCaptureのみを新しく作成してフラッシュモードを反映
- エラーハンドリングとログ出力を改善
改善された点
-
フラッシュモードの切り替え
- フラッシュモードの変更が即座に反映されるようになりました
- フラッシュモードの状態が正しく維持されます
-
カメラ切り替え
- フロント/バックカメラの切り替えがスムーズになりました
- プレビューが途切れることなく表示されます
-
パフォーマンス
- 不要なプレビューの再作成を防止
- メモリ使用量の最適化
-
安定性
- エラーハンドリングの強化
- デバッグ用ログの追加
今後の課題
- ズーム機能の最適化
- カメラの解像度設定の追加
- 撮影時の画質設定の改善
テスト項目
- フラッシュモードの切り替えが正しく機能するか
- カメラの切り替え(フロント/バック)がスムーズに行われるか
- プレビューが途切れることなく表示されるか
- メモリリークが発生していないか
VTuberCamera 変更ログ (2025/06/08)
機能改善
カメラプレビューの安定性向上
- プレビュー画面から戻った後にカメラプレビューが真っ暗になる問題を修正
-
CameraScreen.kt
のLaunchedEffect
にisPreviewMode
を追加し、プレビューモード変更時のカメラ再バインドを確実に実行 -
CameraViewModel.kt
のexitPreviewMode
関数を改善し、カメラの再初期化を確実に実行
-
UI/UX改善
- 写真保存成功時のトーストメッセージを削除
- 不要な通知を減らし、よりクリーンなユーザー体験を提供
- エラー時のトーストメッセージは維持し、問題発生時の通知は継続
技術的な変更点
CameraScreen.kt
// プレビューモード変更時のカメラ再バインド処理を改善
LaunchedEffect(cameraSelector, flashMode, isPreviewMode) {
if (cameraProvider != null && preview != null) {
bindCameraWithPreview(...)
}
}
// 写真保存成功時のトーストメッセージを削除
onPhotoSaved = { /* トーストメッセージを削除 */ }
CameraViewModel.kt
fun exitPreviewMode() {
_isPreviewMode.value = false
// カメラの再初期化を促すために、一時的にカメラセレクターを更新
_cameraSelector.value = _cameraSelector.value
}
影響範囲
- カメラプレビューの動作
- 写真保存時のユーザー通知
テスト項目
- プレビュー画面から戻った後のカメラプレビューが正常に表示されること
- 写真保存成功時にトーストメッセージが表示されないこと
- 写真保存失敗時は引き続きエラーメッセージが表示されること
vtuberCameraアプリの問題修正まとめ 2025/6/11
🔴 発生した問題
写真プレビューからカメラプレビューに戻ると、カメラプレビューが真っ黒になってしまう
🔍 問題の原因分析
元のコードで発見された問題点:
- ViewModelの
exitPreviewMode()
に問題
fun exitPreviewMode() {
_isPreviewMode.value = false
cameraSelector.value = cameraSelector.value // ←問題:値が変わらないため再バインドされない
}
-
CameraXのSurfaceProvider状態管理の問題
- 写真プレビュー表示中にAndroidViewのPreviewViewが非表示
- 戻る際にSurfaceProviderが適切に復元されていない
-
LaunchedEffectの監視対象
-
isPreviewMode
の変更を監視していたが、プレビューモードからの復帰時に確実にカメラ再バインドがトリガーされていない
-
⚠️ 最初の修正の問題
カメラの初期化ロジックを大幅に変更したため、アプリ起動時にカメラが起動しなくなった
✅ 最終的な解決策
CameraViewModel.kt の変更
// 追加
private val _needsCameraRebind = MutableStateFlow(false)
val needsCameraRebind: StateFlow<Boolean> = _needsCameraRebind.asStateFlow()
// 修正
fun exitPreviewMode() {
_isPreviewMode.value = false
_needsCameraRebind.value = true // カメラ再バインドフラグを設定
Log.d("CameraViewModel", "Exiting preview mode, requesting camera rebind")
}
fun clearLastCapturedImage() {
_lastCapturedImageUri.value = null
_isPreviewMode.value = false
_needsCameraRebind.value = true // カメラ再バインドフラグを設定
}
// 追加
fun onCameraRebound() {
_needsCameraRebind.value = false // フラグをリセット
}
CameraScreen.kt の変更
// StateFlowの監視を追加
val needsCameraRebind by viewModel.needsCameraRebind.collectAsStateWithLifecycle()
// LaunchedEffectの修正
LaunchedEffect(cameraSelector, flashMode, needsCameraRebind) {
if (cameraProvider != null && preview != null) {
// needsCameraRebindがtrueの場合は、新しいPreviewを作成
if (needsCameraRebind && previewView != null) {
Log.d("CameraScreen", "Creating new Preview for rebind")
preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView!!.surfaceProvider)
}
viewModel.onCameraRebound() // フラグをリセット
}
bindCameraWithPreview(/* ... */)
}
}
📋 変更のポイント
項目 | 変更前 | 変更後 |
---|---|---|
プレビューモード復帰 | cameraSelector.value = cameraSelector.valueで強制更新 | needsCameraRebindフラグで明示的に管理 |
カメラ初期化 | 元のロジックを保持 | 元のロジックを保持(変更なし) |
SurfaceProvider | 既存のPreviewを再利用 | needsCameraRebind時のみ新しいPreviewを作成 |
状態管理 | StateFlowの値の強制変更 | 専用のフラグによる明示的な制御 |
🎯 解決された問題
✅ アプリ起動時のカメラ起動 - 元のロジックを保持することで正常動作
✅ 写真プレビューからの復帰 - needsCameraRebind
フラグで確実に新しいPreviewを作成
✅ カメラ切り替え - 既存のロジックで正常動作
✅ フラッシュモード変更 - 既存のロジックで正常動作
🔧 実装における教訓
- 最小限の変更原則: 既存の動作コードはできるだけ保持し、問題部分のみを修正
- 明示的な状態管理: 暗黙的な状態変更より明示的なフラグ管理が安全
- 段階的な修正: 大幅な変更よりも段階的な修正でリスクを最小化
- ログ出力の重要性: デバッグ用のログ出力で問題の特定が容易になる
この修正により、CameraXを使用したカメラアプリでよくある「プレビューからの復帰時の黒画面問題」が解決され、安定したカメラ動作が実現されました。