[WIP]CameraXで作るQRコードリーダ
こんにちは、マサトです!
今回の記事はCameraXを使ってQRコードリーダアプリを作成するチュートリアルとなっています。
完成した時のアプリのキャプチャは以下のようになっています。
カメラプレビューを表示しておき、QRコードが画面内に収まると、オーバーレイ表示をします。
TODO 完成図
今回のチュートリアルで学べる内容は以下の三点です。
CameraXはJetpackライブラリの一つで、カメラアプリを作成する際の使用をdeveloperでも推奨されているものになります。
今回はこのCameraXを使用していくのですが、CameraXが提供しているUIのAPIがComposeではなく、Android Viewベースのものになっているので、Compose内でCameraXのViewを使用するために、Compose内でAndroid Viewを使用するための「AndroidViewコンポーザブル」を使用していきます。
このチュートリアルで作成するアプリのコードはGithubにアップロードしていますので、もし詰まってしまった方はご参照ください。
1. プロジェクトの新規作成
それでは、早速アプリの方を作成していきましょう。
まず最初に、プロジェクトの新規作成と各種ライブラリのアップデートを行なっていきます。
テンプレート選択
まずは、テンプレートの選択からです。
テンプレートはEmpty Compose Activityを選択してください。
プロジェクト名はCameraXQrReaderとでもしておきましょう。
プロジェクト名を入れたら後の項目はデフォルトのまま、「Finish」を推しましょう。テンプレートに沿ってプロジェクトが新規作成されます。
ライブラリの追加とアップデート
プロジェクトの新規作成ができたので、次はCameraXの追加とテンプレートによって追加されたライブラリの更新を行います。
appレベルのbuild.gradleを開いて、dependenciesブロックの一番下に以下のようにcameraXの依存関係(& バーコード解析用のMLKitの依存関係)を追加してください。
def camerax_version = "1.3.0-alpha03"
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
implementation "androidx.camera:camera-view:${camerax_version}"
implementation "androidx.camera:camera-mlkit-vision:${camerax_version}"
// MLKit barcode scanning
implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:18.1.0'
次は、各種ライブラリのアップデートを行います。
テンプレートのコードをそのままにすると、最新のライブラリを指定していないので、下の画像のようにワーニングが出てしまいます。これらのワーニングが出ないように、アップデートを行います。
(注: 基本的には、この記事のバージョンと同じものを使用することを推奨します。もし、最新のバージョンを使いたい場合は、この記事を書いた時点とは最新のバージョンが異なると思うので、android studio下部のprobremタブから最新バージョンを確認しながら変更してください。)
まずは、appレベルのbuild.gradeを変更します。
dependencies {
implementation 'androidx.core:core-ktx:1.9.0' // changed
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' // change
implementation 'androidx.activity:activity-compose:1.6.1' // chaged
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
implementation 'androidx.compose.material:material:1.2.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"
dependenciesブロックの上3行のバージョンを上のコードのように変更して
ください。
次は、composeOptionsブロックのcompiler vesionを変更します。
composeOptionsというブロックを探して、versionを1.4.0にせってしてください。
composeOptions {
kotlinCompilerExtensionVersion '1.4.0' // changed
}
次は、projectレベルのbuild.gradleを変更して、使用するcomposeとkotlinのバージョンを上げます。
projectレベルのbuild.gradelを開いて、以下のように変更してください。
buildscript {
ext {
compose_ui_version = '1.3.3' // chagned
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.4.1' apply false
id 'com.android.library' version '7.4.1' apply false
id 'org.jetbrains.kotlin.android' version '1.8.0' apply false // changed
}
以上でプロジェクとの新規作成と依存関係の整理は完了です。
ここで一度、アプリが起動できるか確認してみましょう。
無事にビルドが通って以下のように、「Hello Android!」と表示されるかと思います!
2. カメラパーミッションの取得
カメラを使用するアプリなので、まずは、カメラパーミッションを取得する機能を追加していきます。
まずは、不要なテンプレートコードを削除します。
MainActivityから以下の二つの関数とActivityからGreetingを呼び出している箇所を削除してください。
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
CameraXQrReaderTheme {
Greeting("Android")
}
}
次は、必要なパーミッションをManifestに登録します。
AndroidManifest.xmlファイルを開いて、
applicationタグの上に、Cameraパーミッションを追加してください。
<uses-permission android:name="android.permission.CAMERA" />
次は、Cameraパーミッションをアプリ起動時にリクエストする処理を追加していきます。
MainActivity.ktの一番下に、以下のように関数を追加してください。
カメラパーミッションを要求して、拒否されればアプリを終了し、許可されれば、引数で渡しているonPermissionGrantedというラムダが実行されます。
@Composable
private fun Activity.RequestCameraPermission(
onPermissionGranted: () -> Unit,
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { isGranted ->
if (!isGranted) {
finish()
} else {
onPermissionGranted()
}
}
LaunchedEffect(key1 = Unit) {
launcher.launch(Manifest.permission.CAMERA)
}
}
では、この関数をMainActivityのComposableScopeから呼び出しましょう。
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
// new
RequestCameraPermission {
Log.d("OnPermissionGranted", "Camera")
}
}
パーミッションが許可されたら、ログが出力されるようにしました。
ここまできたら、カメラパーミッションをアプリ起動時に要求する機能は、一通り完成したので、アプリを起動して動作を確認してみましょう。
アプリを起動すると、以下のようにカメラのパーミッションを要求するダイアログが表示されるかと思います。
許可をすると、ログが出力されます。
3. プレビュー画面の作成
それでは、UIの作成に入っていきます。
カメラで撮影している映像を画面に表示するプレビュー画面を作成していきます。
プレビュー画面の作成には、CameraXライブラリのPreviewViewというAPIと、Android ViewであるPreviewViewをCompose内で使用するためのAndroidViewコンポーザブルを使用します。
MainActivityと同じパッケージ内に、新しくCameraPreviewという名前のKotlinファイルを追加して下さい。作成したら、以下のように空のCameraPreviewという名前のコンポーズ関数を追加します。
@Composable
fun CameraPreview() {
}
この中に、後ほどカメラプレビュー画面を作成していきます。
まず、カメラプレビューを表示するにはカメラのライフサイクルとViewのライフサイクルを紐ずける必要があります。CameraPreview関数の下に、以下のようにstartCameraという関数を追加してください。
private fun Context.startCamera(
lifecycleOwner: LifecycleOwner,
vararg useCases: UseCase,
) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
// 背面カメラを使用するように設定
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, *useCases)
} catch(e: Exception) {
e.printStackTrace()
}
}, ContextCompat.getMainExecutor(this))
}
引数として受け取っているUseCaseは、カメラを使って実装したい機能を表しています。今回は、画面へのプレビュー機能を作りたいのでプレビュー機能用のUseCaseインスタンスを外部で作成して、この関数に渡すようにします。
このstartCamera関数を実行すると、カメラのライフサイクルと、Viewのライフサイクルが紐づけられて、カメラを使用することができるようになります。
では、関数を使って、プレビュー画面を作っていきましょう。
先ほど作成した、CameraPreview関数を以下のように変更してください。
@Composable
fun CameraPreview(modifier: Modifier = Modifier) {
val lifecycleOwner = LocalLifecycleOwner.current
AndroidView(
modifier = modifier,
factory = { context ->
val previewView = PreviewView(context).apply {
this.scaleType = scaleType
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
// CameraX プレビューUseCase
val previewUseCase = Preview.Builder()
.build()
.also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
context.startCamera(
lifecycleOwner = lifecycleOwner,
previewUseCase,
)
return@AndroidView previewView
}
)
}
要点の説明
AndroidView(
AndroidViewは、旧来のAndroid ViewをCompose関数の中で使用する際に使用します。CameraXのPreviewViewというものを使用するために使っています。factoryという関数の中で、AndroidViewのインスタンスを作成し、返り値に設定すると、Compose関数内でAndroidViewが使用できます。
val previewView = PreviewView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
この部分で、CameraXのPreviewViewのインスタンスを作成しています。
画面いっぱいにプレビューが表示されるようにしたいので、Viewの高さと幅をMATCH_PARENTに設定しています。
// CameraX プレビューUseCase
val previewUseCase = Preview.Builder()
.build()
.also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
この部分でプレビュー機能用のUseCaseを作成しています。
context.startCamera(
lifecycleOwner = lifecycleOwner,
previewUseCase,
)
先ほど作成したstartCameraにViewのライフサイクルと、プレビュー用のUseCaseを渡してカメラを起動するようにします。
ここまでで、カメラの映像を画面に表示する機能を作成することができました。アプリを起動して、カメラの映像が表示されるか確認してみましょう。
以上でプレビュー画面の作成は完了です。
4. QRコード読み取り機能の追加
QRコード読み取り機能は、MLKitを使用してCameraXのUseCaseを作成する形で実装していきます。
ViewModelの作成
QRコード読み取り機能は少し複雑な機能になるので、アプリのライフサイクルと切り離して実装しやすくするために、ViewModelを作成しましょう。
MainActivityと同じパッケじにMainViewModelという名前で以下のようにViewModelクラスを作成してください。
class MainViewModel : ViewModel() {
}
次に、ActivityでこのViewModelを使用できるようにします。
以下のように、MainActivityに1行追加してください。
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MainViewModel>()
以上でViewModelを作ることができたので、画像を解析して画像内に含まれるQRコードを取得するためのUseCaseを作ります。
QRコード取得UseCaseの作成
MainActivityと同じパッケージに、QrCodeAnalyzerという名前のクラス作成し、以下のようにコードを記述してください。
CameraXのImageAnalyzerというインターフェースを実装しています。
これを使って、後程UseCaseを作成します。
class QrCodeAnalyzer(
private val onQrDetected: (Barcode) -> Unit,
) : ImageAnalysis.Analyzer {
private val qrScannerOptions = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
private val qrScanner = BarcodeScanning.getClient(qrScannerOptions)
@SuppressLint("UnsafeOptInUsageError")
override fun analyze(image: ImageProxy) {
val mediaImage = image.image
// カメラから上手く画像を取得することができているとき
if (mediaImage != null) {
// CameraXで取得した画像をInputImage形式に変換する
val adjustedImage =
InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)
qrScanner.process(adjustedImage)
.addOnSuccessListener {
if (it.isNotEmpty()) {
onQrDetected(it[0])
}
}
.addOnCompleteListener { image.close() }
}
}
}
要点の説明
private val qrScannerOptions = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
private val qrScanner = BarcodeScanning.getClient(qrScannerOptions)
MLKitのBarcodeScannerインスタンス(qrScanner)を生成しています。
このqrScannerインスタンスに画像を解析させることで、画像内のQRコードを取得できます。
@SuppressLint("UnsafeOptInUsageError")
override fun analyze(image: ImageProxy) {
analyzeという関数は、カメラで撮影中、フレームごとに呼び出されます。
今回は、qrScannerに事前にデータタイプや角度を修正した画像データを渡すようにしています。
中身を見ていきましょう。
// CameraXで取得した画像をInputImage形式に変換する
val adjustedImage =
InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)
この部分で、CameraXで取得したカメラ映像をMLKitで処理できる形に変換しています。
qrScanner.process(adjustedImage)
.addOnSuccessListener {
if (it.isNotEmpty()) {
onQrDetected(it[0])
}
}
.addOnCompleteListener { image.close() }
qrScannerに画像データを解析させています。
addOnSuccessListennerに設定したコールバックは、画像解析に成功した場合に呼び出されます。画像解析に成功し、さらに、結果の中にQRコードのデータが入っていれば、コンストラクタの引数で受け取っている、onQrDetectedというコールバックを実行するようにしています。
では、次は、今作成したQrCodeAnalyzerを使用して、UseCaseを追加していきます。
MainViewModelを開いて、以下のようにコードを追加してください。
val qrCodeAnalyzeUseCase = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(
Executors.newSingleThreadExecutor(),
QrCodeAnalyzer {
// TODO
Log.d("OnQrDetected", it.toString())
},
)
}
ImageAnalysis.Builderというものを使うと、先ほどまで作成してきたImageAnalyzer.Analyzerを継承したQrCodeAnalyzerを内包したUseCaseを作成することができます。
QrCodeAnalyzer {
// TODO
Log.d("OnQrDetected", it.toString())
},
onQrDetedtedにログ出力するための処理を設定しておきましょう。
UseCaseを作成することができたので、カメラの方にバインドさせていきましょう。
CameraPreveiwを開いて、以下のように2行追加してください。
@Composable
fun CameraPreview(
modifier: Modifier = Modifier,
vararg useCases: UseCase, // new
) {
...
context.startCamera(
lifecycleOwner = lifecycleOwner,
previewUseCase,
*useCases, // new
)
...
}
これで、引数としてUseCaseを受け取りカメラにバインドさせることができるようになりました。
引数をvaragとすることで複数のUseCseをバインドさせることがきるようにしています。
CameraPreveiwに引数を追加したので、MainActivityの方の呼び出し元も修正しましょう。
CameraPreview(
modifier = Modifier.fillMaxSize(),
viewModel.qrCodeAnalyzeUseCase,
)
viewModelにあるUseCaseインスタンスを引数に渡すようにしました。
ここまでくると、QR読み取り機能が実装できてた状態になっています。
Preview画面にQRコードを表示すると、QrCodeAnalyzerのコンストラクターに設定したコールバックが実行されて、QRコードがログ出力されるようになっています。
実際にアプリを起動して、QRコードを読み込んでみましょう。
画面内にQRコードが収まると、下の画像のようにQRコードがログ出力されるかと思います。
5. ハイライト表示の作成
QRコードの読み取り機能の方が作成できたので、次は読み取ったQRコードの情報を表示するためのUIの作成をしていきます。
作るUIとしては以下のようになります。
// TODO
読みとったQRコードの場所を囲うように正方形の枠を描画し、バーコードの情報を画面下部に表示しています。
解析画像サイズの指定
オーバレイ表示を作成していくにあたって、画像の座標系をオーバレイ表示の座標系に変換します。
その際に画像のサイズを取得する必要があります。
今回は、画像をMLKitで解析する前に、解析対象の画像サイズを固定で設定してしまい、その固定値を使って、座標系の変換を実行していきます。
MainActivityと同じパッケージに、ConstantsというKotlin objectを追加して以下のようにIMAGE_SIZEというプロパティを追加してください。(Sizeはandroid.utilのものをimport)
object Constants {
val IMAGE_SIZE = Size(1080, 1920)
}
このImageSizeを画像解析のサイズに設定してしまいます。
MainViewModelを開いて、以下のように1行追加してください。
val qrCodeAnalyzeUseCase = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setTargetResolution(Constants.IMAGE_SIZE) // new
ハイライト表示の作成
MainActiivtyと同じパッケージ内に、QrCodeOverlayという名前のkotlinファイルを作成し、以下のようにからのCompose関数を定義してください。
@Composable
fun QrCodeOverlay(
qrCode: Barcode,
imageSize: Size = Constants.IMAGE_SIZE,
) {
}
引数には、画面に表示するQRコードのデータを受け取るためのqrCodeという引数と、座標系の変換に使用するimageSize(Sizeはandroid.utilをimportしてください)という引数を設定してください。
MainViewModelで読み取ったQRコードのデータをホストして、このQrCodeOverlay関数に渡すようにしていきます。
MainViewModelに以下のようにStateを追加してください。
private val _qrCode = mutableStateOf<Barcode?>(null)
val qrCode: State<Barcode?> = _qrCode
QRコードが読み取れたら、_qrCodeに読み取ったデータを代入するようにします。MainViewModelのqrCodeAnalysisUseCaseを以下のように変更してください。
val qrCodeAnalyzeUseCase = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setTargetResolution(Constants.IMAGE_SIZE)
.build()
.also {
it.setAnalyzer(
Executors.newSingleThreadExecutor(),
QrCodeAnalyzer { qrCode ->
_qrCode.value = qrCode // new
},
)
}
これで、viewModelで読み取ったQRコードのデータをホストできるようになりました。
ホストしたデータを使って、QrCodeOverlayを呼び出してあげましょう。
MainActivityに以下のようにQrCodeOverlayの呼び出しを追加して下さい。
if (isCameraGranted) {
CameraPreview(
modifier = Modifier.fillMaxSize(),
viewModel.qrCodeAnalyzeUseCase,
)
// new
viewModel.qrCode.value?.let {
QrCodeOverlay(qrCode = it)
}
これで、CameraPreviewの上にQrCodeOverlayが表示されるようになりました。では、QrCodeOverlayの中身を作り込んでいきましょう。
QrCodeOverlayに以下のようにコードを追加してください。
@Composable
fun QrCodeOverlay(
qrCode: Barcode,
imageSize: Size = Constants.IMAGE_SIZE,
) {
val qrBoundingBox = qrCode.boundingBox ?: return
val screenWidthDp = LocalConfiguration.current.screenWidthDp
val screenHeightDp = LocalConfiguration.current.screenHeightDp
with(LocalDensity.current) {
val scaleFactor = (screenHeightDp * density) / imageSize.height
val offsetX = (screenWidthDp - screenHeightDp * imageSize.width / imageSize.height) / 2 * density
val qrCodeTopLeft = Offset(
x = (qrBoundingBox.left.toFloat() + offsetX) * scaleFactor,
y = qrBoundingBox.top.toFloat() * scaleFactor,
)
Canvas(modifier = Modifier.fillMaxSize()) {
drawRect(
color = Color.White,
topLeft = qrCodeTopLeft,
size = androidx.compose.ui.geometry.Size(
width = (qrBoundingBox.right - qrBoundingBox.left) * scaleFactor,
height = (qrBoundingBox.bottom - qrBoundingBox.top) * scaleFactor,
),
style = Stroke(width = 10f),
)
}
}
}
これで、引数として受け取ったQRコードの外接矩形を表示するオーバーレイ表示の方が作成できました。
要点の説明
val qrBoundingBox = qrCode.boundingBox ?: return
MLKitが抽出したQRコードのデータの中から、QRコードの画像内での場所を取得しています。(正確には外接矩形の情報)
val screenWidthDp = LocalConfiguration.current.screenWidthDp
val screenHeightDp = LocalConfiguration.current.screenHeightDp
画像の座標系から、画面UI上の座標系への変換に画面のサイズが必要になってくるので、この部分で取得しています。
val scaleFactor = (screenHeightDp * density) / imageSize.height
val offsetX = (screenWidthDp - screenHeightDp * imageSize.width / imageSize.height) / 2 * density
val qrCodeTopLeft = Offset(
x = (qrBoundingBox.left.toFloat() + offsetX) * scaleFactor,
y = qrBoundingBox.top.toFloat() * scaleFactor,
)
scaleFactorは、画面の高さが画像の高さの何倍なのかを計算した値を入れています。(画像の高さの比がUIと画像の高さの比として扱っていきます)
offsetXは、画像の縦横比率と画面の縦横比率の差によるx軸方向の座標系のずれを修正するのに使用する値を計算したものです。
qrCodeTopLeftは画面QRコードの左上の画面上の座標を表しています。
Canvas(modifier = Modifier.fillMaxSize()) {
drawRect(
color = Color.Green,
topLeft = qrCodeTopLeft,
size = androidx.compose.ui.geometry.Size(
width = (qrBoundingBox.right - qrBoundingBox.left) * scaleFactor,
height = (qrBoundingBox.bottom - qrBoundingBox.top) * scaleFactor,
),
style = Stroke(width = 10f),
)
}
ComposeのCanvasの中で、drawRectという長方形を描画するための関数を使って、QRコードの場所を表す矩形を描画しています。
ここまでで、QRコードを矩形でハイライトするようなオーバーレイのUIを作ることができたので、アプリを起動して、見た目の方を確認していきましょう。
上の画像のように表示されていれば成功です!
補足: 端末を傾けた状態でQRコードを読み取ると、矩形はQRに完全一致せずに、向きはそのままの状態でQRに外接するように表示されるかと思います。
現時点で、MLKitがQRコードの回転情報を返す作りになっていないので、簡単に実装しようとすると外接矩形を表示するまでになってしまいます。(おそらくCameraXのAPI等を調べると、回転させることはできますが、このチュートリアルでは、その対応は行いません。)
斜線描画
矩形を描画するだけだと少し味気ないので、矩形の中に斜線を引くようにしていきましょう。
@Composable
fun QrCodeOverlay(
qrCode: Barcode,
imageSize: Size = Constants.IMAGE_SIZE,
) {
val qrBoundingBox = qrCode.boundingBox ?: return
val screenWidthDp = LocalConfiguration.current.screenWidthDp
val screenHeightDp = LocalConfiguration.current.screenHeightDp
with(LocalDensity.current) {
val scaleFactor = (screenHeightDp * density) / imageSize.height
val offsetX =
(screenWidthDp - screenHeightDp * imageSize.width / imageSize.height) / 2 * density
val qrCodeTopLeft = Offset(
x = (qrBoundingBox.left.toFloat() + offsetX) * scaleFactor,
y = qrBoundingBox.top.toFloat() * scaleFactor,
)
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier
.size(
width = (qrBoundingBox.width() * scaleFactor).toDp(),
height = (qrBoundingBox.height() * scaleFactor).toDp(),
)
.offset {
IntOffset(
x = qrCodeTopLeft.x.toInt(),
y = qrCodeTopLeft.y.toInt(),
)
}
.graphicsLayer { clip = true }
.drawBehind {
drawRect(
color = Color.White,
size = size,
style = Stroke(width = 10f),
)
val steps = 20
val lineSpace = size.width / steps
for (i in 1..steps) {
drawLine(
color = Color.White,
start = Offset(x = (size.width - lineSpace * i) * 2, y = 0f),
end = Offset(x = 0f, y = (size.height - lineSpace * i) * 2),
strokeWidth = 5f,
)
}
}
)
}
}
}
この記事が気に入ったらサポートをしてみませんか?