Zenn
Closed21

M5StackとスマホでIoT

gotoooogotoooo

やりたいこと

  • スマホとM5Stackを組み合わせてよくあるIoTシステムの機能を試したい
  1. M5Stack側の設定をスマホのUIで変更
  2. M5Stack側のファームウェア更新
  3. M5Stackで取得した計測データをスマホで波形表示
  • スマホとM5StackはBlueToothで連携する

何を作るか特に決めていない。
作りながら考える。まずはM5Stack側から。

gotoooogotoooo

冬場の運動不足解消ソリューションを作る。
M5Stackでジャンプ判定し、デバイス内にデータを貯めておく。
任意タイミングでスマホからデバイスに接続し、データを引き抜く。

gotoooogotoooo

M5Stack側

  • IMUでジャンプ判定
  • デバイス内でジャンプログを保持
    ストレージの許す限りためる。いっぱいになったら古いものから削除
  • BlueTooth経由でジャンプ判定しきい値を変更可能
  • BlueTooth経由でジャンプログをスマホに転送可能
  • BlueTooth経由でファーム更新可能
gotoooogotoooo

スマホ側

  • BlueToothデバイスとしてM5Stackを連携
  • ジャンプ判定しきい値設定
  • ジャンプログ取得
  • ジャンプログの可視化
  • ファーム更新判定
gotoooogotoooo

課題

  • IMUでのジャンプ判定アルゴ開発
  • BlueTooth連携(筆者は初の試み)
  • ファーム更新の仕組み(筆者は初の試み)
gotoooogotoooo

なにはともあれM5Stackでのジャンプ判定デバイス開発から進める。
これ単体でもそれなりに有用かもしれない。

gotoooogotoooo

GPTの協力の元あっというまにジャンプ判定アルゴはコーディングできた。
多少の改善点はあるが骨子としてはこれで進める。

ジャンプ判定
main.cpp
#include <Arduino.h>
#include <M5Unified.h>

M5GFX &gfx = M5.Lcd;
float accX, accY, accZ;
const float jumpThreshold = 1.5;// m/s^2l;
bool isJumping = false;
int jumpCount = 0;

void setup() {
  auto cfg  = M5.config();
  cfg.external_imu = true;
  M5.begin(cfg);

  gfx.setTextSize(2);
  gfx.setCursor(0, 0);
  gfx.print("Initializing...");
  delay(1000);

  if (!M5.Imu.begin()){
    gfx.fillScreen(TFT_DARKGRAY);
    gfx.setCursor(0, 0);
    gfx.print("IMU init failed!");
    while(true){
      delay(100);
    }
  }

  gfx.fillScreen(TFT_BLACK);
  gfx.setCursor(0, 0);
  gfx.print("Ready!");
}

void loop() {
  M5.update();

  M5.Imu.getAccel(&accX, &accY, &accZ);

  if(accZ > jumpThreshold && !isJumping){
    isJumping = true;
    gfx.fillScreen(TFT_BLACK);
    gfx.setCursor(0, 0);
    gfx.setTextColor(TFT_GREEN);
    gfx.println("Jump detected!");
    jumpCount++;
    delay(500);
  }
  else if( accZ < 0.5){
    isJumping = false;
  }

  gfx.setCursor(0, 40);
  gfx.setTextColor(TFT_WHITE, TFT_BLACK);
  gfx.printf("AccX: %.2f\nAccY: %.2f\nAccZ: %.2f", accX, accY, accZ);
  gfx.setCursor(0, 80);
  gfx.printf("Count: %d", jumpCount);

  delay(100);
}

gotoooogotoooo

M5Stackと言いつつ使用するのはM5StickCPlusであることをここに言及しておく。

前回投稿からの変更点

  • 描画をSprite(M5GFXの場合はCanvas)を使用することでちらつき防止
  • RTOSでマルチタスクっぽい書き方に変更
  • ボタン長押しでペアリングモードに変更する部分の状態切り替え処理のみ実装

ボタン長押し判定はBtnA.pressedFor関数でお手軽に判定できた。
もう一度ボタンを押すと元の状態に切り替わる動作を実装するにあたって、意外と手こずり、canChangeState変数を設けて状態切替タイミングを制御することで解決できた。もっと良い方法はありそう。

次回投稿でいよいよBlueTooth接続するあたりまで進めたい。

main.cpp
main.cpp
#include <Arduino.h>
#include <M5Unified.h>
#include <M5GFX.h>

M5GFX &gfx = M5.Lcd;
M5Canvas canvas(&gfx);
float accX, accY, accZ;
const float jumpThreshold = 1.5; // m/s^2l;
bool isJumping = false;
int jumpCount = 0;
bool isButtonPressed = false;
bool isButtonLongPressed = false;
bool canChangeState = true;

typedef enum
{
  normalState,
  pairingState
} StateType;

StateType state = normalState;

TaskHandle_t handlePollingDeviceTask, handleDetectJumpTask, handleControlStateTask, handleUpdateScreenTask;

void updateScreenTask(void *arg)
{
  canvas.createSprite(gfx.width(), gfx.height());
  canvas.setTextSize(2);

  for (;;)
  {
    canvas.fillScreen(TFT_BLACK);
    if (isJumping)
    {
      canvas.setCursor(0, 0);
      canvas.setTextColor(TFT_GREEN);
      canvas.println("Jump detected!");
    }

    canvas.setCursor(0, 40);
    canvas.setTextColor(TFT_WHITE, TFT_BLACK);
    canvas.printf("AccX: %.2f\nAccY: %.2f\nAccZ: %.2f\n", accX, accY, accZ);
    canvas.printf("Count: %d\n", jumpCount);
    canvas.printf("%s\n", state==normalState?"normal":state==pairingState?"pairing":"other");
    canvas.printf("%s, %s, %s\n", isButtonPressed ? "Y" : "N", isButtonLongPressed ? "Y" : "N", canChangeState ? "Y" : "N");

    gfx.startWrite();
    canvas.pushSprite(0, 0);
    gfx.endWrite();

    delay(100);
  }
}

void detectJumpTask(void *arg){

  for (;;){
    if (accZ > jumpThreshold && !isJumping)
    {
      isJumping = true;
      jumpCount++;
      delay(500);
    }
    else if (accZ < 0.5)
    {
      isJumping = false;
    }
    delay(100);
  }
}

void pollingDeviceTask(void *arg){

  for(;;){
    M5.update();
    M5.Imu.getAccel(&accX, &accY, &accZ);
    isButtonPressed = M5.BtnA.isPressed();
    isButtonLongPressed = M5.BtnA.pressedFor(3000);
    canChangeState |= M5.BtnA.wasReleased();
    delay(100);
  }
}

void controlStateTask(void *arg){
  for(;;){
    if (state == normalState && canChangeState){
      if (isButtonLongPressed){
        state = pairingState;
        canChangeState = false;
      }
    }
    else if (state == pairingState && canChangeState){
      if (isButtonPressed){
        state = normalState;
        canChangeState = false;
      }
    }

    delay(100);
  }
}

void setup()
{
  auto cfg = M5.config();
  cfg.external_imu = true;
  M5.begin(cfg);

  gfx.setCursor(0, 0);
  gfx.print("Initializing...");
  delay(1000);

  if (!M5.Imu.begin())
  {
    gfx.fillScreen(TFT_DARKGRAY);
    gfx.setCursor(0, 0);
    gfx.print("IMU init failed!");
    while (true)
    {
      delay(100);
    }
  }

  gfx.fillScreen(TFT_BLACK);
  gfx.setCursor(0, 0);
  gfx.print("Ready!");

  xTaskCreate(pollingDeviceTask, "pollingDeviceTask", 2048, NULL, 1, &handlePollingDeviceTask);
  xTaskCreate(detectJumpTask, "detectJumpTask", 1024, NULL, 1, &handleDetectJumpTask);
  xTaskCreate(controlStateTask, "controlStateTask", 1024, NULL, 1, &handleControlStateTask);
  xTaskCreate(updateScreenTask, "updateScreenTask", 2048, NULL, 2, &handleUpdateScreenTask);
}

void loop()
{
  delay(100);
}

gotoooogotoooo

↓のような初期化処理を実装し、ペアリングモードに遷移したタイミングでBLEDevice::startAdvertising()を呼ぶことでスマホからBluetoothデバイスとして認識されるようになった。
ちなみにペアリングモードを解除する際にはBLEDevice::stopAdvertising()を呼ぶ。

次回はBluetooth経由でジャンプ判定しきい値を変更する仕組みの実装にトライしたい。

main.cpp
main.cpp

#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEService.h>
#include <BLEUtils.h>

BLEServer *pServer = nullptr;

#define SERVICE_UUID "12345678-1234-1234-1234-123456789abc" // 任意のUUID
#define CHARACTERISTIC_UUID "abcdefab-1234-5678-1234-abcdefabcdef"

void setupBluetooth(){
  BLEDevice::init("gk-m5stickcplus");
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new BLEServerCallbacks());

  BLEService *pService = pServer->createService(BLEUUID(SERVICE_UUID));

  BLECharacteristic *pCharacteristic = pService->createCharacteristic(
    CHARACTERISTIC_UUID,
    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE
  );

  pService->start();

  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x06);
  pAdvertising->setMinPreferred(0x12);
}

gotoooogotoooo

↓のようなコールバッククラスを追加し、初期化処理の中でコールバックを登録することでスマホからジャンプ判定しきい値を変更できるようになった。
ちなみにBLEのデバッグ用ツールはいくつかあるようであり、今回はAndroidの「nRF Connect for Mobile」を使用した。

次回はスマホ側にデータを転送する処理の実装にトライしたい。

参考:
https://qiita.com/tomoya0x00/items/28c3b92abbc3b0983178

main.cpp
main.cpp

BLECharacteristic *pCharacteristic = nullptr;

class MyCallbacks : public BLECharacteristicCallbacks{
  void onWrite(BLECharacteristic *pCharacteristic){
    std::string value = pCharacteristic->getValue();
    if (value.length() <= 0){
      return;
    }

    float newThreshold = atof(value.c_str());
    if (newThreshold <= 0.1 || 5.0 < newThreshold){
      return;
    }

    jumpThreshold = newThreshold;
  }
};

void setupBluetooth(){
  BLEDevice::init("gk-m5stickcplus");
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new BLEServerCallbacks());

  BLEService *pService = pServer->createService(BLEUUID(SERVICE_UUID));

  pCharacteristic = pService->createCharacteristic(
    CHARACTERISTIC_UUID,
    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE
  );
  pCharacteristic->setCallbacks(new MyCallbacks()); // <= add

  pService->start();

  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x06);
  pAdvertising->setMinPreferred(0x12);
}
gotoooogotoooo

BLE側からスマホにデータを転送するにあたって、スマホからのRead要求に対してデータを渡す方法と、BLEから一方的にデータを渡す方法があるようである。今回は前者を採用する。

↓のように前回作成したMyCallbacksクラスでonReadをオーバーライドし、pCharacteristic->setValue(buffer) でデータを渡す。

ここまでで基本的なデータ転送の仕組みを実装できた。
次回はジャンプのログとして有用なデータとなるよう、BLE側のプログラムを改良していきたい。

main.cpp
main.cpp
class MyCallbacks : public BLECharacteristicCallbacks{
  void onRead(BLECharacteristic *pCharacteristic){
    char buffer[10];
    snprintf(buffer, sizeof(buffer), "%d", jumpCount);
    pCharacteristic->setValue(buffer);
  }

  void onWrite(BLECharacteristic *pCharacteristic){
// 略
  }
};
gotoooogotoooo

M5Stick側の処理をブラッシュアップ

  • データ管理
     ジャンプログを1024回分メモリで保持する。
      ジャンプログ={セッションのユニークID, 起動からの経過時間、回数}
    ※EEPROMに保持するかは今後検討。書き込み回数に上限があるためむやみにEEPROMを使うのは危険。

  • BLE連携
    onReadでジャンプ回数に応じてジャンプログを転送する。onWriteでジャンプ判定しきい値を更新し、EEPROMに設定値を不揮発的に保持する。

ペアリング完了したら元の状態に戻す処理を加えたかったが実現方法がよくわかっていない。一旦保留。
だいたい完成に近づいたので次回はスマホ側の開発を進める。

gotoooogotoooo

予想してはいたが不慣れなAndroidアプリ開発で苦戦中。

View方式とJetpackComposeとでどちらか選択することになったが、WPF, Flutter, Reactなど過去経験したアプリ開発は宣言的UIだったので同じく宣言的UIのJetpackComposeを採用することとした。

HelloWorld的なサンプルから始め、少しずつUIを実装し始めた段階で、頭の整理のためアプリ(画面)とBLEの連携をちゃんと設計することにした。

ひとまず最小限の機能のシーケンスを検討。
ジャンプしている最中のデータをBLEとAppとで同期するかは作りながら考える。
リアルタイムにスマホにデータを送る恩恵があるかどうか。
飛んだらマリオのジャンプ音が出る みたいな仕掛けができれば楽しそうではある。ただBLE側でできなくもない。

sequence

gotoooogotoooo

格闘の末、M5StickをAdvertise状態にしてスマホから接続対象としてScanするところまで実装できた。

ハマりポイント

  • 何を勘違いしたのか、仮想デバイスでデバッグしていたため、BLE実機とどうやっても接続できない環境でデバッグし続けていた。
    => USBでAndroid実機を接続し、実機デバッグすることで解決

  • Bluetoothの許諾周りの作法に翻弄される
    => いくつか段階を踏む必要あり

  1. AndroidManifest.xmlにパーミッションを記述
  2. Scanボタン押下時の処理の中で許諾状態をチェック。未許可なら許諾ダイアログを出す。許可済みならスキャンロジックを呼ぶ。
  3. スキャンロジックの中でもスキャン結果のプロパティを参照する前に許諾状態をチェックし、許可されていれば当該プロパティにアクセスして情報を得る。
gotoooogotoooo

CleanなArchitectureの実装を試みる中で色々ハマる。

ハマりポイント

  • ライブラリの追加方法
    VersionCatalogの使用が推奨されているようであるが、Web上の記事には新旧の情報が混在しており、正しい手順の把握に苦労した。
    ProjectStructureからライブラリを検索して追加する方法と諸々の設定ファイル(versions.toml, build.gradle)に直接記述する方法がある。前者のほうがミスが少なそうではある。

  • kapt->ksp
    そもそもkaptやkspの前提知識がない状態でこれらに依存したライブラリを追加しようとし、小一時間混乱していた。 kaptは非推奨でkspへの以降が推奨されている。しかしながらこれも新旧の情報が混在しており、正しい導入手順を把握するのに時間を要した。

  • Hiltを使ったDI
    WPFでの開発を通じてDIの便利さは理解している。
    Hiltを使ってDIするためにはいくつかの手順が必要であり、既存クラスにアノテーションを追加すれば良いものもあれば新たにクラスを作成するものもあり多少の労力を要する。

JetpackCompose x VersionCatalog x ksp x Hilt の組み合わせでHelloWorldするアプリの環境構築方法をまとめておいても良いかもしれない。

gotoooogotoooo

ダイアログの制御ではまる。
navControllerでダイアログを呼び出して結果を呼び出し元に返す方法がよくわからず諦めた。
おとなしく呼び出し元のStateの中でダイアログ表示フラグを管理するなど呼び出し元とダイアログとで状態を共有することにした。
ダイアログのライフサイクルに合わせて状態を更新しなければならないが比較的シンプルな構成である。

gotoooogotoooo

コルーチンの処理のキャンセル方法のメモ。

viewModelScope.launch{~~}の戻り値でjobが返ってくる。
これに対してjob.cancel()すると処理を中断できる。
ViewModelクラスにJob?のフィールドを持たせておき、startXXX, cancelXXXのメソッド内でJobをハンドリングすればよさげ。

gotoooogotoooo

AndroidからBLEデバイスにConnectしてReadする部分でドハマリしている。
BLEにConnectしてReadする際に諸々非同期でコールバックが呼ばれる。
それとUI側の待機処理がうまく噛み合わず例外を処理しきれずアプリがクラッシュする現象と目下格闘中。

gotoooogotoooo

GPTとタッグを組んだ格闘の末、どうにかBLEからReadできるようになった。

備忘録として主要なソースコードを残しておく。
GPTに言われるがままに修正したコードであるが、苦戦したポイントは下記二点。
・Kotlin特有の非同期処理(未だに理解不足、要勉強)
・ネイティブなbluetoothライブラリでのコールバック処理とKotlin非同期処理の相性

viewmodel
BleDeviceDetailViewModel.kt

@HiltViewModel
class BleDeviceDetailViewModel @Inject constructor(
    private val connectBleDeviceUseCase: ConnectBleDeviceUseCase,
    private val readCharacteristicUseCase: ReadCharacteristicUseCase
) : ViewModel() {
    private val _state = mutableStateOf(BleDeviceDetailState())
    val state: State<BleDeviceDetailState> = _state

    fun connectDevice(device: BleDevice){
        _state.value = _state.value.copy(name = device.name, address = device.address)

        connectBleDeviceUseCase.invoke(device).onEach { result ->
            when (result) {
                is BleResponse.Success -> {
                    _state.value = _state.value.copy(isConnected = true)
                }
                is BleResponse.Loading -> {
                    _state.value = _state.value.copy(isConnected = false)
                }
                is BleResponse.Failure -> {
                    _state.value = _state.value.copy(isConnected = false)
                }
            }
        }.launchIn(viewModelScope)
    }

    fun readValue() {
        viewModelScope.launch {
            readCharacteristicUseCase().collect { result ->
                when (result) {
                    is BleResponse.Success -> {
                        _state.value = _state.value.copy(receivedValue = result.data)
                    }
                    is BleResponse.Loading -> {
                        _state.value = _state.value.copy(receivedValue = null)
                    }
                    is BleResponse.Failure -> {
                        _state.value = _state.value.copy(receivedValue = null)
                    }
                }
            }
        }
    }

    fun writeValue(command: String, value: Int) {
        // TODO
    }
}
usecase
ReadCharacteristicUseCase.kt
class ReadCharacteristicUseCase @Inject constructor(
    private val repository : BleDeviceRepository
) {
    operator fun invoke() :Flow<BleResponse<String>> = callbackFlow {
        try {
            trySend (BleResponse.Loading())

            repository.readCharacteristic {
                rx -> trySend(BleResponse.Success(rx))
            }
            awaitClose()
        }
        catch (e: Exception){
            trySend(BleResponse.Failure(e.message?: "Unknown error"))
            close()
        }
    }
}
repository
BleDeviceRepositoryImpl

class BleDeviceRepositoryImpl @Inject constructor(
    private val context: Context
) : BleDeviceRepository {

    private data class JumpLog(
        val activityId: UInt,
        val timestamp: UInt,
        val count: UInt
    )

    private fun parseJumpLog(byteArray: ByteArray): List<JumpLog> {
        if (byteArray.size % 12 != 0) {
            throw IllegalArgumentException("Invalid byte array size: ${byteArray.size}, must be multiple of 12")
        }

        val buffer = ByteBuffer.wrap(byteArray).order(ByteOrder.LITTLE_ENDIAN)
        val logs = mutableListOf<JumpLog>()

        while (buffer.remaining() >= 12) {
            logs.add(
                JumpLog(
                    activityId = buffer.int.toUInt(),
                    timestamp = buffer.int.toUInt(),
                    count = buffer.int.toUInt()
            ))
        }
        return logs
    }

    private var bluetoothGatt: BluetoothGatt? = null
    private var readValueCallback : (String) -> Unit = {}

    private val serviceUuid = UUID.fromString("/*secret*/")
    private val characteristicUuid = UUID.fromString("/*secret*/")

    override suspend fun scanBleDevices(scanDuration: Duration): ScanBleDevicesResultDto {
        val bluetoothManager = context.getSystemService(BluetoothManager::class.java)
        val bluetoothAdapter = bluetoothManager?.adapter
        val bluetoothScanner = bluetoothAdapter?.bluetoothLeScanner ?: return ScanBleDevicesResultDto()

        return suspendCancellableCoroutine { continuation ->
            val scanResults = mutableListOf<BleDeviceDto>()
            val handler = Handler(Looper.getMainLooper())

            val scanCallback = object : ScanCallback() {
                override fun onScanResult(callbackType: Int, result: ScanResult) {
                    checkPermission()
                    val device = result.device
                    if (!scanResults.any { it.address == device.address }){
                        scanResults.add(BleDeviceDto(device.name ?: "Unknown", device.address))
                    }
                }

                override fun onBatchScanResults(results: MutableList<ScanResult>) {
                    checkPermission()
                    for (result in results) {
                        val device = result.device
                        if (!scanResults.any { it.address == device.address }){
                            scanResults.add(BleDeviceDto(device.name ?: "Unknown", device.address))
                        }
                    }
                }

                override fun onScanFailed(errorCode: Int) {
                    continuation.resume(ScanBleDevicesResultDto()) // スキャン失敗時
                }
            }

            bluetoothScanner.startScan(null, ScanSettings.Builder().build(), scanCallback)

            handler.postDelayed({
                bluetoothScanner.stopScan(scanCallback)
                continuation.resume(ScanBleDevicesResultDto(scanResults, scanResults.size) )
            }, scanDuration.inWholeMilliseconds)
        }
    }

    override suspend fun connectBleDevice(device: BleDevice) {
        val bluetoothManager = context.getSystemService(BluetoothManager::class.java)
        val bluetoothAdapter = bluetoothManager?.adapter
        val foundDevice = bluetoothAdapter?.getRemoteDevice(device.address)
        if (foundDevice == null){
            Log.e("BLE", "Device was not found")
            return
        }

        if (bluetoothGatt == null){
            checkPermission()
            bluetoothGatt = foundDevice.connectGatt(context, false, gattCallback)
        }
    }

    private val gattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
            if (newState == BluetoothProfile.STATE_CONNECTED){
                checkPermission()
                bluetoothGatt?.discoverServices()
            }
            else if (newState == BluetoothProfile.STATE_DISCONNECTED){
                checkPermission()
                bluetoothGatt?.close()
            }
        }

        override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
            if (status == BluetoothGatt.GATT_SUCCESS){
                bluetoothGatt = gatt
                Log.d("BLE", "Service was discovered")
            }
        }

        override fun onCharacteristicRead(
            gatt: BluetoothGatt,
            characteristic: BluetoothGattCharacteristic,
            value: ByteArray,
            status: Int
        ) {
            if (status == BluetoothGatt.GATT_SUCCESS){
                Log.d("BLE", String(value))
                val logs = parseJumpLog(value)
                val receivedValue = "${logs.last().count}"
                readValueCallback(receivedValue)
            }
        }
    }

    override suspend fun readCharacteristic(onReceived: (String) -> Unit) {
        bluetoothGatt?.let { gatt ->
            val characteristic = gatt.getService(serviceUuid)?.getCharacteristic(characteristicUuid)
            if (characteristic != null) {
                readValueCallback = onReceived
                checkPermission()
                gatt.readCharacteristic(characteristic)
            } else {
                Log.e("BLE", "Characteristic was not found")
            }
        } ?: Log.e("BLE", "BluetoothGatt is null")
    }

    private fun checkPermission(){
        if (ActivityCompat.checkSelfPermission(
                context,
                Manifest.permission.BLUETOOTH_CONNECT
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            // TODO: Consider calling
            //    ActivityCompat#requestPermissions
            // here to request the missing permissions, and then overriding
            //   public void onRequestPermissionsResult(int requestCode, String[] permissions,
            //                                          int[] grantResults)
            // to handle the case where the user grants the permission. See the documentation
            // for ActivityCompat#requestPermissions for more details.
            return
        }
    }
}
gotoooogotoooo

当初の目論見の30%ぐらいしか達成できていないが、Bluetooth機器のデバイス-スマホ連携の大枠は経験できたため一旦閉める。
労力の大半はAndroidアプリ開発の習得に費やした形となる。
もう少しJetpackComposeやKotlinの非同期処理に熟れたらリファクタリングを兼ねてブラッシュアップを図りたい。

このスクラップは1ヶ月前にクローズされました
ログインするとコメントできます