NCMB Kotlin SDKを使ってTodoアプリを作る

NCMBでは公式SDKとしてSwift/Objective-C/Kotlin/Java/Unity/JavaScript SDKを用意しています。また、それ以外にもコミュニティSDKとして、非公式ながらFlutter/React Native/Google Apps Script/C#/Ruby/Python/PHPなど幅広い言語向けにSDKが開発されています。

今回は公式SDKの一つ、Kotlin SDKを使ってTodoアプリを作ってみます。まず画面の仕様とSDKの初期化について解説します。

## 完成版のコード

作成したデモアプリのコードは[NCMBMania/Kotlin_Todo: Kotlin SDKを使ったTodoアプリです](https://github.com/NCMBMania/Kotlin_Todo)にアップロードしてあります。

## ベースについて

今回はKotlin + Jetpack Composeの組み合わせになっています。

[NCMBMania/Kotlin_Todo_Handson](https://github.com/NCMBMania/Kotlin_Todo_Handson)

## Composeについて

今回は以下のComposeを用意しています。

- Navigation
- FormScreen
- ListScreen
- TaskRow
- DetailScreen

### Navigation

各画面への遷移をハンドリングしています。Navigationコンポーネントを利用しています。

#### FormScreen

![Screenshot_20220414_155917.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/f956bebd-4fa5-230a-c9a8-5e1fe838894c.png)


タスクの入力フォームです。新規登録、編集の両方で利用しています。

#### ListScreen

![Screenshot_20220414_155935.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/535bde76-e711-1500-07e2-a2d641947b09.png)


タスクを一覧表示します。各行のデータはTaskRowにて表示しています。

#### TaskRow

タスクの一覧表示で、各行を表示するのに使います。

#### DetailScreen

![Screenshot_20220414_155946.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/f2449883-9409-21d1-bbbe-90fd9531ddab.png)


一覧表示で選択された、タスクの詳細表示を行います。

## NCMB SDKのインストール

NCMB SDKは[Releases · NIFCLOUD-mbaas/ncmb_kotlin](https://github.com/NIFCLOUD-mbaas/ncmb_kotlin/releases)よりダウンロードします。Zipファイルをダウンロードして展開すると、NCMB.jarというファイルが取得できます。このファイルをKotlinプロジェクトの `app/libs` 以下にコピーします。

これでNCMB SDKの利用準備が整います。

## NCMB SDKの初期化

`app/build.gradle` を開いて編集します。そして以下の4つを追加します。一番下のkotlinx-coroutinesを追加すると、保存処理などが同期的に処理できるようになります。

```
dependencies {
	// 省略
  implementation 'com.squareup.okhttp3:okhttp:4.8.1'
	implementation 'com.google.code.gson:gson:2.3.1'
	api files('libs/NCMB.jar')
	implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9'
}
```

編集したら同期します。

### コードの編集

MainActivity.ktを開いてNCMBの初期化を行います。 `super.onCreate` の下でNCMBを初期化します。 `YOUR_APPLICATION_KEY` と `YOUR_CLIENT_KEY` はそれぞれあなたのものと書き換えてください。

```kotlin
// 1. NCMBをインポート
import com.nifcloud.mbaas.core.NCMB

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 2. NCMBを初期化
        NCMB.initialize(
            this,
            "YOUR_APPLICATION_KEY",
            "YOUR_CLIENT_KEY"
        )
        setContent {
						// 省略
        }
    }
}
```

これでNCMBの初期化が終わり、利用準備が整います。

## ナビゲーションの振り分け

今回は以下のようなナビゲーションになっています。文字列からNCMBObjectを復元するstrToNCMBObjectについては、[KotlinのNavigation コンポーネントを使ってNCMBObjectを別画面に渡す - Qiita](https://qiita.com/goofmint/items/a0cd211fbcaafb375352#strtoncmbobject%E3%81%AE%E5%87%A6%E7%90%86)を参照してください。

```kotlin
// 1. NCMBObjectをインポートします
import com.nifcloud.mbaas.core.NCMBObject

@Composable
fun Navigation() {
    val startDestination = "list"
    val navController = rememberNavController()
		// NCMBObject/NCMBQueryで扱うクラス名
    val className = "Task"
    NavHost(navController = navController, startDestination = startDestination) {
				// 入力画面
        composable(route = "form") {
						// 2. 新しいNCMBObjectを作成
            val obj = NCMBObject(className)
            FormScreen(navController, obj)
        }
				// 一覧画面
        composable(route = "list") {
            ListScreen(navController = navController)
        }
				// 詳細画面
        composable(
            route = "detail/task={task}",
            arguments = listOf(navArgument("task") { type = NavType.StringType})
        ) { backStackEntry ->
						// 3. 受け取った文字列からNCMBObjectを復元します
            val obj = strToNCMBObject(backStackEntry.arguments!!.getString("task")!!, className)
            DetailScreen(navController, obj)
        }
				// 編集画面
        composable(
            route = "edit/task={task}",
            arguments = listOf(navArgument("task") { type = NavType.StringType})
        ) { backStackEntry ->
						// 4. 受け取った文字列からNCMBObjectを復元します
            val obj = strToNCMBObject(backStackEntry.arguments!!.getString("task")!!, className)
            FormScreen(navController, obj)
        }
    }
}
```

## Util.ktについて

文字列をNCMBObjectに戻す関数です。

```kotlin
// 1. NCMBAcl/NCMBObjectを読み込みます
import com.nifcloud.mbaas.core.NCMBObject
import com.nifcloud.mbaas.core.NCMBAcl

// 2. 文字列をNCMBObjectに復元する関数です
fun strToNCMBObject(str: String, className: String): NCMBObject {
    val json = JSONObject(str)
    val obj = NCMBObject(className = className)
    json.keys().forEach { key ->
        when (key) {
            "objectId" -> {
                obj.setObjectId(json.get(key) as String)
            }
            "createDate" -> {
                obj.setCreateDate(getDate(json.get(key) as String))
            }
            "updateDate" -> {
            } // obj.put("updateDate", getDate(json.get(key) as String))}
            "acl" -> {
                obj.setAcl(getAcl(json.get(key) as JSONObject))
            }
            else -> obj.put(key, json.get(key))
        }
    }
    return obj
}

// 3. NCMBAclを復元する関数です
fun getAcl(obj: JSONObject): NCMBAcl {
    val acl = NCMBAcl()
    Log.d("INFO", obj.toString())
    obj.keys().forEach { key ->
        val map = obj.get(key) as JSONObject
        when {
            key == "*" -> {
                if (!map.isNull("read") && map.get("read") as Boolean) {
                    acl.publicReadAccess = true
                }
                if (!map.isNull("write") && map.get("write") as Boolean) {
                    acl.publicWriteAccess = true
                }
            }
            key.indexOf("role:") > -1 -> {
                val match = "role:(.*)$".toRegex().find(key)
                val roleName = match?.groups?.get(1)?.value
                if (roleName != null) {
                    if (!map.isNull("read") && map.get("read") as Boolean) {
                        acl.setRoleReadAccess(roleName, true)
                    }
                    if (!map.isNull("write") && map.get("write") as Boolean) {
                        acl.setRoleWriteAccess(roleName, true)
                    }
                }
            }
            else -> {
                if (!map.isNull("read") && map.get("read") as Boolean) {
                    acl.setUserReadAccess(key, true)
                }
                if (!map.isNull("write") && map.get("write") as Boolean) {
                    acl.setUserWriteAccess(key, true)
                }
            }
        }
    }
    return acl
}
```

## タスクの登録

タスクの登録と編集は `FormScreen` にて行います。ナビゲーションから新規登録の場合は空のNCMBObject（クラス名はTask）、編集時には対象となるNCMBObjectを受け取ります。 `objectId` の有無によって、タスクのタイトルと本文のデフォルト値を変えています。

```kotlin
// 1. NCMBExceptionとNCMBObjectを読み込みます
import com.nifcloud.mbaas.core.NCMBException
import com.nifcloud.mbaas.core.NCMBObject

// 2. AnyをNCMBObjectに変更します
fun FormScreen(navController: NavController, obj: NCMBObject) {
    // 3. objectIdを入れます
    var objectId = obj.getObjectId();
    // 4. titleをrememberで定義します。デフォルト値はNCMBObjectのtitleです
    var title by remember { mutableStateOf(if (objectId != null) obj.getString("title")!! else "") }
    // 5. bodyをrememberで定義します。デフォルト値はNCMBObjectのbodyです
    var body by remember { mutableStateOf(if (objectId != null) obj.getString("body")!! else "") }
    val header = if (title == "") "新規作成" else "編集"
    // 処理実行中のフラグ
    var progress by remember { mutableStateOf(false) }
    // ダイアログ表示/非表示のフラグ
    var showDialog by remember { mutableStateOf(false) }
    // 表示するメッセージ
    var message by remember { mutableStateOf("") }
    // ダイアログを表示する処理
    if (showDialog) {
        AlertDialog(
            onDismissRequest = {},
            buttons = {
                Button(onClick = {
                    showDialog = false
                }) {
                    Text("OK")
                }
            },
            title = { Text("タスク保存") },
            text = { Text(message) }
        )
    }
		// 保存処理（後述）
    val save = {
    }
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text("タスクの${header}")
                },
                actions = {
                    IconButton(
                        onClick = {
                        }
                    ){
                        Icon(Icons.Filled.Save, contentDescription = "Save")
                    }
                }
            )
        },
        content = {
            Column {
                OutlinedTextField(
                    value = title,
                    onValueChange = {
                        Log.d("INFO", it)
                        title = it
                                    },
                    modifier = Modifier.padding(20.dp),
                    label = { Text("タスクのタイトル") },
                    singleLine = true
                )
                OutlinedTextField(
                    value = body,
                    onValueChange = { body = it },
                    modifier = Modifier.padding(20.dp).height(150.dp),
                    label = { Text("タスクの本文") },
                    )
                Button(onClick = save,
                    enabled = !progress,
                    modifier = Modifier.padding(20.dp)
                ) {
                    if (progress) {
                        CircularProgressIndicator(
                            strokeWidth = 2.dp,
                            modifier = Modifier.size(24.dp)
                        )
                    } else {
                        Text("保存する")
                    }
                }
            }
        }
    )
}
```

![Screenshot_20220414_155917.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/4e386dcd-2eab-adff-a91e-9e93a7c4a6e8.png)

### 保存処理

保存処理は以下のようになります。 `progress` のフラグを立てて、二重処理を防止しています。入力されたタスクのタイトルと本文を設定し、保存を行います。保存後はナビゲーションで一覧へ戻ります。

```kotlin
// 保存処理
val save = {
		progress = true
		// 6. 保存処理を記述します
		try {
				obj.put("title", title)
				obj.put("body", body)
				obj.save()
				navController.navigate("list")
		} catch(e: NCMBException){
				Log.d("INFO",  e.message)
				message = "タスクが保存できませんでした"
				showDialog = true
		}
		progress = false
}
```

## タスクデータの取得

データストアに保存したタスクデータを取得、表示するのは `ListScreen` になります。取得したデータは `ary` の中に入ります。後は `ary` を `LazyColumn` で一覧表示します。

```kotlin
// 1. NCMBCallback/NCMBObject/NCMBQueryを読み込みます
import com.nifcloud.mbaas.core.NCMBCallback
import com.nifcloud.mbaas.core.NCMBObject
import com.nifcloud.mbaas.core.NCMBQuery

@Composable
fun ListScreen(navController: NavController) {
    // 2. aryをrememberのNCMBObjectのListで定義します。デフォルトは空です
    var ary = remember { mutableStateOf<List<NCMBObject>>(emptyList()) }
    // 3. NCMBQueryを定義します。クラス名はTaskです。
    val query = NCMBQuery.forObject("Task")
    // 4. 検索を実行して、結果をaryに格納します
    query.findInBackground(NCMBCallback { e, results ->
        if (e == null) {
            ary.value = results as List<NCMBObject>
        }
    })
    
		// 画面構築
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text("タスク一覧")
                },
                actions = {
                    IconButton(
                        onClick = {
                            navController.navigate("form")
                        }
                    ){
                        Icon(Icons.Filled.Add, contentDescription = "タスクの作成")
                    }
                }
            )
        },
        content = {
            Column {
                LazyColumn {
                    items(ary.value) { obj ->
                        TaskRow(obj, navController = navController)
                    }
                }
            }
        }
    )
}
```

![Screenshot_20220414_155935.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/c1f36b9b-b0ae-9cd4-52ce-2cca3773f7ef.png)

### データの取得

データを取得する際には `NCMBQuery` を使います。そして取得したデータはAny型なので、 `List<NCMBObject>` にキャストします。

```kotlin
// 3. NCMBQueryを定義します。クラス名はTaskです。
val query = NCMBQuery.forObject("Task")
// 4. 検索を実行して、結果をaryに格納します
query.findInBackground(NCMBCallback { e, results ->
		if (e == null) {
				ary.value = results as List<NCMBObject>
		}
})
```

### データの表示

リストデータの一行あたりのデータは `TaskRow` で表示しています。ここではデータをタップした際に、 `moveDetail` 関数にて詳細画面へ遷移しています。ここではNCMBObjectの文字列化をした上で画面遷移しています。

```kotlin
// 1. NCMBObjectを読み込みます
import com.nifcloud.mbaas.core.NCMBObject
import org.json.JSONObject

@Composable
// 2. Any -> NCMBObjectにします
fun TaskRow(obj: NCMBObject, navController: NavController) {
    // 詳細画面に遷移する処理です
    val moveDetail = {
        val json = JSONObject()
        // 3. NCMBObjectを文字列化します
        obj.keys.forEach {key ->
            json.put(key, obj.get(key)!!)
        }
        navController.navigate("detail/task=${json.toString()}")
    }
    Row(
        verticalAlignment = Alignment.CenterVertically,
        modifier = Modifier
            .fillMaxWidth()
            .padding(10.dp)
            .background(Color.White, RoundedCornerShape(5.dp))
            .clickable {
                moveDetail()
            }
    ) {
        // 4. NCMBObjectのtitleを表示します
        Text(obj.getString("title")!!, fontSize = 25.sp)
    }
}
```

## 詳細画面の表示

詳細画面では送られてきたNCMBObjectを表示しています。なお、削除ボタンをタップした際には該当データの `delete` メソッドでデータ削除の上、一覧画面へ遷移しています。編集ボタンをタップした際には、画面遷移した時と同じくNCMBObjectを文字列化した上で編集画面に遷移しています。

NCMBObjectはgetStringで文字列としてデータをとれますが、Nullableなので注意してください。

```kotlin
// 1. NCMBObjectをインポート
import com.nifcloud.mbaas.core.NCMBObject

@Composable
// 2. Any -> NCMBObjectに変更します
fun DetailScreen(navController: NavController, obj: NCMBObject) {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text("タスク")
                },
                actions = {
                    IconButton(
                        onClick = {
                            val json = JSONObject()
                            // 3. NCMBObjectを文字列化します
                            obj.keys.forEach {key ->
                                json.put(key, obj.get(key)!!)
                            }
                            navController.navigate("edit/task=${json.toString()}")
                        }
                    ){
                        Icon(Icons.Filled.Edit, contentDescription = "Edit")
                    }
                    IconButton(
                        onClick = {
                            // 4. NCMBObjectを削除します
                            obj.delete()
                            navController.navigate("list")
                        }
                    ){
                        Icon(Icons.Filled.Delete, contentDescription = "Delete")
                    }
                }
            )
        },
        content = {
            Column {
                // 5. NCMBObjectのtitleを表示します
                Text(text = obj.getString("title")!!,
                    fontSize = 35.sp,
                    modifier = Modifier.padding(5.dp)
                )
                // 6. NCMBObjectのbodyを表示します
                Text(text = obj.getString("body")!!,
                    fontSize = 30.sp,
                    modifier = Modifier.padding(5.dp)
                )
            }
        }
    )
}
```

![Screenshot_20220414_155946.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/80c3ee64-d654-251b-2525-bb3ef6dd266a.png)