OpenAIのAPIを使って、Kotlin Multiplatform Mobile(KMM) で API周りの共通化を学んでみる

Kotlin Multiplatform Mobile(KMM)を使ってコアロジック部分を共通化してAndroid/iOSで共通なコアを使ったアプリをそれぞれつくってみた。という記事です。

はじめまして。もしくはおひさしぶりです。タクシーアプリ「GO」の開発や技術研究調査などをしているこいづかです。

いろいろなモバイル共通開発の紹介

モバイルアプリ、と一口に言ってもAndroid用なのかiOS用なのかどちらかが先に作られたとしても市場に出すと、ユーザーが「逆の端末用はないの?」となり、遅かれ早かれ広まってくる頃には両方に対応させることが多くなっていきます。 エンジニアを両方で雇って対応させることがすぐに出来ればいいのですが、それでもロジック周りでの違い、UIの違い、触り心地などのUXの違いなどがユーザーから指摘され日々大変なことになっていくことだと思います。

それもあり、両対応で最初から作ろうとする選択肢を取る企業や団体なども増えており、そのための選択肢として、

などがあり、現在(2023年3月)ではどれも帯に短したすきに長しの状況であり日進月歩とはいえ「これ」という決定打がないままに進んでいることが多く見られます。 (Flutterが一番人気で、Kotlinは二番手な感じですね)

今回はそのうちの一つ 「Kotlin Multiplatform Mobile」を利用して両対応のアプリケーションのコア部分(API、providerModule、CoreModule)を作り、UI部分は各OS上で表現していこう。という物を紹介していきます。

Kotlin Multiplatform Mobile はどういうものか?

Kotlin Multiplatform Mobile は JetBrains社によってオープンソースで開発されているプログラミング言語であるKotlinを利用して、複数プラットフォームに対応したアプリケーションを開発する技術であり、Android/iOSで共通の箇所を作れることが売りとなっています。

何よりもAndroidの最新の開発環境である Kotlin を使用することにより Android開発者にとってはなじみ深い lib がそのまま使えるというのは利点の一つではないでしょうか。

iOS開発者に取ってもロジックがAndroidと剥離することなく作業出来るので各OSによっての差違が小さくなる(UI部分やUX部分はどうしてもNativeで書いていくことになり、ずれは生じていきます。この辺はFlutterやUnity、ReactNativeなども内包する課題の一つにはなります)ことも良いことでしょう。

逆にネガティブな話としては、コアをKotlinで書いていくことになるためiOSで培ったObjective-CやSwiftの技術などはあまり考慮されにくくなり、コア開発がKotlinエンジニアに依存していく形になりがちという点は気をつけないといけない点かと思います。(両方学べば問題解決……ですが、中々難しいですよね……)

そしてもう一つあるのは 2023年3月現在、まだ「beta」である。(執筆時点で 0.5.2 となります)ということは、まだまだ開発途上であるので「1.0」まで信用出来ない。というところも多いかと思います。(上に説得するのにbetaと言ってというのは中々難しいものです……

ちょっとこのあたりはネックになって使うのをためらうことに繋がっているのかもしれません。

Kotlin Multiplatform Mobile でアプリを作る

Kotlin Multiplatform Mobile Projectの作成

説明はこれくらいにして、アプリを作っていきましょう。

まずはKotlin開発なので、Android Studio を用意します。

わたしの環境はMac (AppleSilicon)なので、それ用のものをdownloadしてinstallをしていきました。 起動すると、Android Appなどを選択して進めて行くことになりますが、わたしがdownloadしたAndroid Studioには KMM(これ以降 Kotlin MultiPlatform Mobileのことを頭文字を取りKMMと略していきます) の一覧がありません。この場合 Plugin で KMMを追加していく必要があります。

立ち上げて Settings → Plugin から、Kotlin Multiplatform Mobile を選択して

installしたら画面の指示にしたがってRestartします。

そうすると、起動した際に、new project templates 一覧の下の方に、Kotlin Multiplatform App と Kotlin Multiplatform Library が追加されています。 今回は新規Appを作るので、Kotlin Multiplatform Appを選びました。 (画面下の左の方です。アイコンは同じなので判りにくいですね)

new project templatesの一覧画面

次にKMMのプロジェクト名などを設定していきます。 名前、パッケージ名(これはドメインネームになりますので所属ドメインを後ろから記入していきます) Save LocationはAndroidStudioProjectsの下に作られるので変えたいときは適時変更しましょう(通常はそのままでかまいません) Minimum SDKAPI 24か26以上であれば問題ないかと思いますが、Jetpack Compose  でUIまわりを書きたかったのでAPI 29にしておきました。

そして、Android側でのAppの名前、iOS側でのAppの名前、(iOS側からincludeする)shared Moduleの名前、iOS frameworkとしてどの形にするか? を選びます。 iOS側では最近どこも Swift Package Manager化をしているところが多いので、 Reguler framework を選んでおけば問題ないかと思います。(前に出て来た選択で KMM Libを選べば XCFrameworkの作成も選べます。Appの方では選べないのでその点注意が必要です)

ここまで進んだらあとは、libを選んでincludeして実装していけば出来ます。 installしたあとのfiletreeはこんな感じです。

installしたあとのfiletreeその1

installしたあとのfiletreeその2

あとはenjoy。 ……。 というのも味気ないので、最近話題のものを組み込んで見たのでサンプルとして出しておきます。

ChatGPT AI (ChatGPT-3.5) を組み込んでみよう

OpenAIの作ったAI、ChatGPT4が最近(2023年3月)人気ですので、ChatGPT(3.5。4はまだwaitinglist待ちなので来たら考えます)のAPIを組み込んで、テキストを入力したらChatGPT APIに答えてもらうアプリにしましょうか。

まずは使える様にするために OpenAIに登録して、openapi key を取って来ます。

OpenAIに登録したら、アカウントのViewAPIKeyから manage api keyを選んでそれをコピーしておきます。(sk- なんちゃら、ってなってるやつです) そしてmanifest のusers-permissionで INTERNETを使えるようにします。(これをやらないと内部での通信がダメ)

そうしたら、CommonMainでlibを使うために build.gradle.kt を書き換えます。

CommonやCommonMainというのはKMMで使う共通部分のことになり、APIなどのiOS/Androidで使う共通部分になります。共通部分を作ってからAndroidのほうはこちら、iOSの方はこちらと分岐して作成していくことになります。

(Project 側の build.gradle.kt)

plugins{
//trick: for the same plugin versions in all sub-modules
    id("com.android.application").version("7.4.2").apply(false)
    id("com.android.library").version("7.4.2").apply(false)
    id("com.google.devtools.ksp").version("1.5.30-1.0.0-beta08").apply(false)
kotlin("android").version("1.8.0").apply(false)
kotlin("multiplatform").version("1.8.0").apply(false)
    id("org.jetbrains.kotlin.plugin.serialization").version("1.6.21").apply(false)
}

tasks.register("clean", Delete::class){
  delete(rootProject.buildDir)
}

seriarization(json構文解釈のため)を追加してあります

(shared側の build.gradle.kt)

plugins{
    kotlin("multiplatform")
        id("com.android.library")
        id("kotlinx-serialization")
}

kotlin{
    android{
        compilations.all{
            kotlinOptions{
                jvmTarget = "1.8"
            }
    }
  }

    listOf(
      iosX64(),
    iosArm64(),
    iosSimulatorArm64()
    ).forEach{
    it.binaries.framework{
            baseName = "shared"
        }
  }

    sourceSets{
        val ktorVersion = "2.2.4"
    val commonMain bygetting{
            dependencies{
                implementation("io.ktor:ktor-client-core:$ktorVersion")
        implementation("io.ktor:ktor-client-serialization:$ktorVersion")
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
            }
    }
        val commonTest bygetting{
            dependencies{
                implementation(kotlin("test"))
            }
    }
        val androidMain bygetting{
            dependencies{
                implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
        implementation("io.ktor:ktor-client-serialization:$ktorVersion")
            }
    }
        val androidUnitTest bygetting
        val iosX64Main bygetting
        val iosArm64Main bygetting
        val iosSimulatorArm64Main bygetting
        val iosMain bycreating{
            dependsOn(commonMain)
      iosX64Main.dependsOn(this)
      iosArm64Main.dependsOn(this)
      iosSimulatorArm64Main.dependsOn(this)
        dependencies{
                    implementation("io.ktor:ktor-client-darwin:$ktorVersion")
          implementation("io.ktor:ktor-client-serialization:$ktorVersion")
                }
      }
            val iosX64Test bygetting
            val iosArm64Test bygetting
            val iosSimulatorArm64Test bygetting
            val iosTest bycreating{
                dependsOn(commonTest)
        iosX64Test.dependsOn(this)
        iosArm64Test.dependsOn(this)
        iosSimulatorArm64Test.dependsOn(this)
            }
    }
    }

    android{
        namespace = "jp.goinc.chatapp"
    compileSdk = 33
    defaultConfig{
            minSdk = 29
      targetSdk = 33
        }
    }
    dependencies{
        implementation("com.kotlinx:kotlinx:1.0.7")
    }
}

coroutinesとかktor(API通信用)とかを追加してあります

(AndroidApp側の build.gradle.kt )

plugins{
    id("com.android.application")
    kotlin("android")
  id("kotlinx-serialization")
}

android{
    namespace = "jp.goinc.chatapp.android"
  compileSdk = 33
  defaultConfig{
        applicationId = "jp.goinc.chatapp.android"
    minSdk = 29
    targetSdk = 33
    versionCode = 1
    versionName = "1.0"
    }
    buildFeatures{
        compose = true
    }
    composeOptions{
        kotlinCompilerExtensionVersion = "1.4.0"
    }
    packagingOptions{
        resources{
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
  }
    buildTypes{
        getByName("release"){
            isMinifyEnabled = false
        }
  }
    compileOptions{
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    kotlinOptions{
        jvmTarget = "1.8"
    }
}

dependencies{
    implementation(project(":shared"))
    implementation("androidx.compose.ui:ui:1.3.3")
    implementation("androidx.compose.ui:ui-tooling:1.3.3")
    implementation("androidx.compose.ui:ui-tooling-preview:1.3.3")
    implementation("androidx.compose.foundation:foundation:1.3.1")
    implementation("androidx.compose.material:material:1.3.1")
    implementation("androidx.activity:activity-compose:1.6.1")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
}

これもcommonと同じ様に使える様にしてあります

common側のsource

先ほども書きました共通部分であるcommon側を書いていきます。

Greeting.kt

package jp.goinc.chatapp
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*

class Greeting(content: String) {
    private val jsonString = """
{"model": "gpt-3.5-turbo",
 "messages":
   [{"role": "user",
     "content": "${content}!"
   }]
 }
  """.trimIndent()

    val postString = ChatAIPostData(
    "gpt-3.5-turbo",

        listOf(
        Message("system","""
あなたはChatbotとして、刑事にして名探偵である「右京 圭介」としてロールプレイを行います。
以下の制約条件を厳密に守ってロールプレイを行ってください。 

制約条件: 
* Chatbotの自身を示す一人称は、わたしです。
* Userを示す二人称は、あなたです。
* Chatbotの名前は、右京圭介です。
* 右京圭介は刑事です。
* 右京圭介は探偵と呼ばれることもあります、が本職は刑事ですのでちょっと困ります。
* 右京圭介の口調は丁寧ですが、たまに突然話を切ってから文献などの長文をすらすらと出し、最後にどこからの引用かという言葉でしめることがあります。
* 右京圭介の口調は、「ふむふむ」「〜ですね」「〜ですか……なるほど、面白い」「〜ということでよろしいですね?」など語りかけるような言葉を好みます。
* 右京圭介はUserを信用してはいません。ですが見下しもしていません。
* 一人称は「わたし」を使ってください。

右京圭介のセリフ、口調の例:
* ふむふむ、あなたの質問は
* あなたの質問は、わたしに届きました。では、答えましょう
* 少し難しいですね。ちょっと文献から引用してもいいでしょうか……。ではいきます。

右京圭介の行動指針:
* Userの言葉を丁寧に聞いて下さい
* Userの矛盾に気がついたら、「〜なるほど、面白い」と言ってください
* セクシャルな話題については誤魔化してください。
""".trimIndent()
            ),
    Message("user",content)))

    @Serializable
  data class ChatAIPostData (
      val model: String,
    val messages: List<Message>
  )

  // result class
  @Serializable
  data class ChatAIResult (
    val id: String,

    @SerialName("object")
    val chatAIResultObject: String,

    val created: Long,
    val model: String,
    val usage: Usage,
    val choices: List<Choice>
  )

  @Serializable
  data class Choice (
    val message: Message,

    @SerialName("finish_reason")
    val finishReason: String,

    val index: Long
  )

  @Serializable
  data class Message (
    val role: String,
    val content: String
  )

  @Serializable
  data class Usage (
    @SerialName("prompt_tokens")
    val promptTokens: Long,

    @SerialName("completion_tokens")
    val completionTokens: Long,

    @SerialName("total_tokens")
    val totalTokens: Long
  )

  private val client = HttpClient()

  suspend fun greeting(): String {
    val response = client.post("https://api.openai.com/v1/chat/completions"){
            headers{
                append(HttpHeaders.ContentType, "application/json")
        append(HttpHeaders.Authorization, "Bearer {ここにskから始まるopenaiのapikey}")
            }
            contentType(ContentType.Application.Json)
            setBody(Json.encodeToString(postString))
        }
        val result = Json.decodeFromString<ChatAIResult>(response.bodyAsText())
    return result.choices.first().message.content
  }
}

chatBotの条件分は自作ですので、探偵ものとしてai探偵との会話を楽しんでください。 ※ 右京圭介は拙作のTRPGでの探偵ロールによくいる人です。オリジナルなので改変ご自由にどうぞ

Android側のsource

MainActivity.kt

package jp.goinc.chatapp.android

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import jp.goinc.chatapp.Greeting
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
            setContent{
                var searchText byremember{mutableStateOf("")}
                var searchResult byremember{mutableStateOf("Loading")}
                MyApplicationTheme{
                    Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colors.background
          ){
                        Column(Modifier.padding(16.dp)){
                            val scope =rememberCoroutineScope()
                            OutlinedTextField(
                value = searchText,
                onValueChange ={searchText =it },
                label ={Text("Search")},
                keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
                keyboardActions = KeyboardActions(onDone ={
// テキストフィールドのリターンキーが押された時に呼び出される
                    scope.launch{
// APIにリクエストを送信し、結果をsearchResultに保存する
                      searchResult = try {
                        Greeting(searchText).greeting()
                    } catch (e: Exception) {
                      e.localizedMessage?: "error"
                    }
                                    }
                }
                            )
            )
                        Spacer(modifier = Modifier.height(16.dp))
                      Text(searchResult)
                  }
        }
      }
      }
  }
}

上にTextfieldがあって、そのTextfieldに文字をいれてリターンを押す(ないしキーボードからの完了)と、それがAPIに渡されて結果がTextfieldの下のTextに表示されるというコードになります。

これでcommon(共通部分)とAndroidアプリ側のソース周りはぜんぶとなりますが、念のため、Android Studioの plugin 周りのところも出しておきます。(無事installされているなら不必要かもしれません。)

iOS側のsource

iOS側はいっぺんfinderに戻り(mac環境なのでfinderです)そこから iosApp→iosApp.xcodeprojを選んでXcodeで追記していきます

その前にXcodeのproject→target→Build PhasesのRunScriptで warningが出てるので直しておきます

ここの「Based on dependency analysis」にcheckが入っている場合は外しておきます。 そこからiOSのコード(Swift部分)を修正していきます

iOSApp.swift

import SwiftUI

@main
struct iOSApp: App {
    var body: some Scene {
        WindowGroup {
      ContentView()
        }
    }
}

元と比較して、ContentView()という修正です

ContentView.swift

import SwiftUI
import shared

struct ContentView: View {
  @State private var searchText: String = ""
  @State private var searchResult: String = "Loading"
    
  var body: some View {
    MyApplicationTheme {
      ZStack {
        Color.white.ignoresSafeArea()
        VStack(spacing: 16) {
          TextField("", text:$searchText, onCommit: {
            Greeting(content: searchText).greeting { greeting, error in
              DispatchQueue.main.async {
                if let greeting = greeting {
                  self.searchResult = greeting
                } else {
                  self.searchResult = error?.localizedDescription ?? "error"
                }
              }
            }
          }).padding()
          .background(Color.white)
          .cornerRadius(8)
          .overlay(
            RoundedRectangle(cornerRadius: 8)
            .stroke(Color.gray, lineWidth: 1)
          )
          Text(searchResult)
          Spacer()
        }
        .padding()
      }
    }
  }
}

struct MyApplicationTheme<Content: View>: View {
  let content: () -> Content

  init(@ViewBuilder content: @escaping () -> Content) {
    self.content = content
  }

  var body: some View {
    content()
    .foregroundColor(.black)
    .font(.system(size: 17))
  }
}

こんな感じになります。Androidと似た感じにするためRoundedRectangleでTextfieldをかこってあります。

出来たアプリを起動しての画面

Android

Androidアプリの起動画面

iOS

iOSアプリの起動画面

API通化して作ってみて

api周りを共通化してみると、先にAndroidの方で十分に動きを確認しておく必要があることや、UIなどでかなり違う部分が発生してデザインを先に十分に検討しておかないと「おなじようなもの」にはなりにくいことが改めて感じます。

ですが、共通部分を作ることにより機種ごとの差違が減るのは確かですのでその辺のメリットデメリットなど十分に考慮すればとても良い開発方法といえるのではないでしょうか?