以下文章來(lái)源于Android高效開(kāi)發(fā),作者2BAB
作者 / Android 谷歌開(kāi)發(fā)者專家 El Zhang (2BAB)
在今年的廈門和廣州 Google I/O Extended 上,我分享了《On-Device Model 集成 (KMP) 與用例》。本文是當(dāng)時(shí) Demo 的深入細(xì)節(jié)分析,同時(shí)也是后面幾篇同類型文章的開(kāi)頭。通過(guò)本文你將了解到:
移植 Mediapipe 的 LLM Inference Android 官方 Demo 到 KMP,支持在 iOS 上運(yùn)行。
KMP 兩種常見(jiàn)的調(diào)用 iOS SDK 的方式:
Kotlin 直接調(diào)用 Cocoapods 引入的第三方庫(kù)。
Kotlin 通過(guò) iOS 工程調(diào)用第三方庫(kù)。
KMP 與多平臺(tái)依賴注入時(shí)的小技巧 (基于 Koin)。
On-Device Model 與 LLM 模型 Gemma 1.1 2B 的簡(jiǎn)單背景。
On-Device Model 本地模型
大語(yǔ)言模型 (LLM) 持續(xù)火熱了很長(zhǎng)一段時(shí)間,而今年開(kāi)始這股風(fēng)正式吹到了移動(dòng)端,包括 Google 在內(nèi)的最新手機(jī)與系統(tǒng)均深度集成了此類 On-Device Model 的相關(guān)功能。對(duì)于 Google 目前的公開(kāi)戰(zhàn)略中,On-Device Model 這塊的大語(yǔ)言模型主要分為兩個(gè):
Gemini Nano: 非開(kāi)源,支持機(jī)型較少 (某些機(jī)型支持特定芯片加速如 Tensor G4),具有強(qiáng)勁的表現(xiàn)。目前可以在桌面平臺(tái) (Chrome) 和部分 Android 手機(jī)上使用 (Pixel 8/9 Samsung 和小米部分機(jī)型)。據(jù)報(bào)道晚些時(shí)候會(huì)公開(kāi)給更多的開(kāi)發(fā)者進(jìn)行使用和測(cè)試。
Gemma: 開(kāi)源,支持所有滿足最低要求的機(jī)型,同樣有不俗的性能表現(xiàn),與 Nano 使用類似的技術(shù)路線進(jìn)行訓(xùn)練。目前可以在多平臺(tái)上體驗(yàn) (Android/iOS/Desktop)。
目前多數(shù)移動(dòng)端開(kāi)發(fā)者尚無(wú)法直接基于 Gemini Nano 開(kāi)發(fā),所以今天的主角便是 Gemma 1 的 2B 版本。想在移動(dòng)平臺(tái)上直接使用 Gemma,Google 已給我們提供一個(gè)開(kāi)箱即用的工具: Mediapipe。MediaPipe 是一個(gè)跨平臺(tái)的框架,它封裝了一系列預(yù)構(gòu)建的 On-Device 機(jī)器學(xué)習(xí)模型和工具,支持實(shí)時(shí)的手勢(shì)識(shí)別、面部檢測(cè)、姿態(tài)估計(jì)等任務(wù),還可應(yīng)用于生成圖片、聊天機(jī)器人等各種應(yīng)用場(chǎng)景。感興趣的朋友可以試玩它的 Web 版 Demo,以及相關(guān)文檔。
而其中的 LLM Inference API (上表第一行),用于運(yùn)行大語(yǔ)言模型推理的組件,支持 Gemma 2B/7B,Phi-2,F(xiàn)alcon-RW-1B,StableLM-3B 等模型。針對(duì) Gemma 的預(yù)轉(zhuǎn)換模型 (基于 TensorFlow Lite) 可在 Kaggle 下載,并在稍后直接放入 Mediapipe 中加載。
LLM Inference
Android Sample
Mediapipe 官方的 LLM Inference Demo 包含了 Android/iOS/Web 前端等平臺(tái)。
打開(kāi) Android 倉(cāng)庫(kù)會(huì)發(fā)現(xiàn)幾個(gè)特點(diǎn):
純 Kotlin 實(shí)現(xiàn)。
UI 是純 Jetpack Compose 實(shí)現(xiàn)。
依賴的 LLM Task SDK 已經(jīng)高度封裝,暴露出來(lái)的方法僅 3 個(gè)。
再查看 iOS 的版本:
UI 是 SwiftUI 實(shí)現(xiàn),做的事情和 Compose 一模一樣,稍微再簡(jiǎn)化掉一些元素 (例如 Topbar 和發(fā)送按鈕)。
依賴的 LLM Task SDK 已經(jīng)高度封裝,暴露出來(lái)的方法一樣為 3 個(gè)。
所以,一個(gè)好玩的想法出現(xiàn)了:Android 版本的這個(gè) Demo 具備移植到 iOS 上的基礎(chǔ);移植可使兩邊的代碼高度高度一致,大幅縮減維護(hù)成本,而核心要實(shí)現(xiàn)的僅僅是橋接下 iOS 上的 LLM Inference SDK。
Kotlin Multiplatform
移植工程所使用的技術(shù)叫做 Kotlin Multiplatform (縮寫為 KMP),它是 Kotlin 團(tuán)隊(duì)開(kāi)發(fā)的一種支持跨平臺(tái)開(kāi)發(fā)的技術(shù),允許開(kāi)發(fā)者使用相同的代碼庫(kù)來(lái)構(gòu)建 Android、iOS、Web 等多個(gè)平臺(tái)的應(yīng)用程序。通過(guò)共享業(yè)務(wù)邏輯代碼,KMP 能顯著減少開(kāi)發(fā)時(shí)間和維護(hù)成本,同時(shí)盡量保留每個(gè)平臺(tái)的原生性能和體驗(yàn)。Google 在今年的 I/O 大會(huì)上也宣布對(duì) KMP 提供一等的支持,把一些 Android 平臺(tái)上的庫(kù)和工具遷移到了多平臺(tái),KMP 的開(kāi)發(fā)者可以方便的使用它到 iOS 等其他平臺(tái)。
盡管 Mediapipe 也支持多個(gè)平臺(tái),但我們這次主要聚焦在 Android 和 iOS。一方面更貼近現(xiàn)實(shí),各行各業(yè)使用 KMP 的公司的用例更多在移動(dòng)端上;另外一方面也更方便對(duì)標(biāo)其他移動(dòng)端開(kāi)發(fā)技術(shù)棧。
移植流程
初始化
使用 IDEA 或 Android Studio 創(chuàng)建一個(gè) KMP 的基礎(chǔ)工程,你可以借助 KMP Wizard 或者第三方 KMP App 的模版。如果你沒(méi)有 KMP 的相關(guān)經(jīng)驗(yàn),可以看到它其實(shí)就是一個(gè)非常類似 Android 工程的結(jié)構(gòu),只不過(guò)這一次我們把 iOS 的殼工程也放到根目錄,并且在 app 模塊的 build.gradle.kts 內(nèi)同時(shí)配置了 iOS 的相關(guān)依賴。
封裝和調(diào)用 LLM Inference
我們?cè)?commonMain 中,根據(jù) Mediapipe LLM Task SDK 的特征抽象一個(gè)簡(jiǎn)單的接口,使用 Kotlin 編寫,用以滿足 Android 和 iOS 兩端的需要。該接口取代了原有倉(cāng)庫(kù)里的 InferenceModel.kt 類。
// app/src/commonMain/.../llm/LLMOperator interface LLMOperator { /** * To load the model into current context. * @return 1. null if it went well 2. an error message in string */ suspend fun initModel(): String? fun sizeInTokens(text: String): Int suspend fun generateResponse(inputText: String): String suspend fun generateResponseAsync(inputText: String): Flow在 Android 上面,因?yàn)?LLM Task SDK 原先就是 Kotlin 實(shí)現(xiàn)的,所以除了初始化加載模型文件,其余的部分基本就是代理原有的 SDK 功能。> }
class LLMInferenceAndroidImpl(private val ctx: Context): LLMOperator { private lateinit var llmInference: LlmInference private val initialized = AtomicBoolean(false) private val partialResultsFlow = MutableSharedFlow>(...) override suspend fun initModel(): String? { if (initialized.get()) { return null } return try { val modelPath = ... if (File(modelPath).exists().not()) { return "Model not found at path: $modelPath" } loadModel(modelPath) initialized.set(true) null } catch (e: Exception) { e.message } } private fun loadModel(modelPath: String) { val options = LlmInference.LlmInferenceOptions.builder() .setModelPath(modelPath) .setMaxTokens(1024) .setResultListener { partialResult, done -> // Transforming the listener to flow, // making it easy on UI integration. partialResultsFlow.tryEmit(partialResult to done) } .build() llmInference = LlmInference.createFromOptions(ctx, options) } override fun sizeInTokens(text: String): Int = llmInference.sizeInTokens(text) override suspend fun generateResponse(inputText: String): String { ... return llmInference.generateResponse(inputText) } override suspend fun generateResponseAsync(inputText: String): Flow > { ... llmInference.generateResponseAsync(inputText) return partialResultsFlow.asSharedFlow() } }
而針對(duì) iOS,我們先嘗試第一種調(diào)用方式:直接調(diào)用 Cocoapods 引入的庫(kù)。在 app 模塊引入 cocoapods 的插件,同時(shí)添加 Mediapipe 的 LLM Task 庫(kù):
// app/build.gradle.kts plugins { ... alias(libs.plugins.cocoapods) } cocoapods { ... ios.deploymentTarget = "15" pod("MediaPipeTasksGenAIC") { version = "0.10.14" extraOpts += listOf("-compiler-option", "-fmodules") } pod("MediaPipeTasksGenAI") { version = "0.10.14" extraOpts += listOf("-compiler-option", "-fmodules") } }
注意上面的引入配置中要添加一個(gè)編譯參數(shù)為 -fmodules 才可正常生成 Kotlin 的引用 (參考鏈接)。
一些 Objective-C 庫(kù),尤其是那些作為 Swift 庫(kù)包裝器的庫(kù),在它們的頭文件中使用了 @import 指令。默認(rèn)情況下,cinterop 不支持這些指令。要啟用對(duì) @import 指令的支持,可以在 pod() 函數(shù)的配置塊中指定 -fmodules 選項(xiàng)。
之后,我們?cè)?iosMain 中便可直接 import 相關(guān)的庫(kù)代碼,如法炮制 Android 端的代理思路:
// 注意這些 import 是 cocoapods 開(kāi)頭的 import cocoapods.MediaPipeTasksGenAI.MPPLLMInference import cocoapods.MediaPipeTasksGenAI.MPPLLMInferenceOptions import platform.Foundation.NSBundle ... class LLMOperatorIOSImpl: LLMOperator { private val inference: MPPLLMInference init { val modelPath = NSBundle.mainBundle.pathForResource(..., "bin") val options = MPPLLMInferenceOptions(modelPath!!) options.setModelPath(modelPath!!) options.setMaxTokens(2048) options.setTopk(40) options.setTemperature(0.8f) options.setRandomSeed(102) // NPE was thrown here right after it printed the success initialization message internally. inference = MPPLLMInference(options, null) } override fun generateResponse(inputText: String): String {...} override fun generateResponseAsync(inputText: String, ...) :... { ... } ... }
但這回我們沒(méi)那么幸運(yùn),MPPLLMInference 初始化結(jié)束的一瞬間有 NPE 拋出。最可能的問(wèn)題是因?yàn)?Kotlin 現(xiàn)在 interop 的目標(biāo)是 Objective-C,MPPLLMInference 的構(gòu)造器比 Swift 版本多一個(gè) error 參數(shù),而我們傳入的是 null。
constructor( options: cocoapods.MediaPipeTasksGenAI.MPPLLMInferenceOptions, error:CPointer>?)
但幾番測(cè)試各種指針傳入,也并未解決這個(gè)問(wèn)題:
// 其中一種嘗試 memScoped { val pp: CPointerVar> = allocPointerTo() val inference = MPPLLMInference(options, pp.value) Napier.i(pp.value.toString()) }
于是只能另辟蹊徑采用第二種方案: 通過(guò) iOS 工程調(diào)用第三方庫(kù)。
// 1. 聲明一個(gè)類似 LLMOperator 的接口但更簡(jiǎn)單,方便適配 iOS 的 SDK。 // app/src/iosMain/.../llm/LLMOperator.kt interface LLMOperatorSwift { suspend fun loadModel(modelName: String) fun sizeInTokens(text: String): Int suspend fun generateResponse(inputText: String): String suspend fun generateResponseAsync( inputText: String, progress: (partialResponse: String) -> Unit, completion: (completeResponse: String) -> Unit ) } // 2. 在 iOS 工程里實(shí)現(xiàn)這個(gè)接口 // iosApp/iosApp/LLMInferenceDelegate.swift class LLMOperatorSwiftImpl: LLMOperatorSwift { ... var llmInference: LlmInference? func loadModel(modelName: String) async throws { let path = Bundle.main.path(forResource: modelName, ofType: "bin")! let llmOptions = LlmInference.Options(modelPath: path) llmOptions.maxTokens = 4096 llmOptions.temperature = 0.9 llmInference = try LlmInference(options: llmOptions) } func generateResponse(inputText: String) async throws -> String { return try llmInference!.generateResponse(inputText: inputText) } func generateResponseAsync(inputText: String, progress: @escaping (String) -> Void, completion: @escaping (String) -> Void) async throws { try llmInference!.generateResponseAsync(inputText: inputText) { partialResponse, error in // progress if let e = error { print("(self.errorTag) (e)") completion(e.localizedDescription) return } if let partial = partialResponse { progress(partial) } } completion: { completion("") } } ... } // 3. iOS 再把代理好的(重點(diǎn)是初始化)類傳回給 Kotlin // iosApp/iosApp/iosApp.swift class AppDelegate: UIResponder, UIApplicationDelegate { ... func application(){ ... let delegate = try LLMOperatorSwiftImpl() MainKt.onStartup(llmInferenceDelegate: delegate) } } // 4. 最初 iOS 在 KMP 上的實(shí)現(xiàn)細(xì)節(jié)直接代理給該對(duì)象(通過(guò)構(gòu)造器注入) class LLMOperatorIOSImpl( private val delegate: LLMOperatorSwift) : LLMOperator { ... }細(xì)心的朋友可能已經(jīng)發(fā)現(xiàn),兩端的 Impl 實(shí)例需要不同的構(gòu)造器參數(shù),這個(gè)需求一般使用 KMP 的 expect 與 actual 關(guān)鍵字解決。下面的代碼中:
利用了 expect class 不需要構(gòu)造器參數(shù)聲明的特點(diǎn)加了層封裝 (類似接口)。
利用了 Koin 實(shí)現(xiàn)各自平臺(tái)所需參數(shù)的注入,再統(tǒng)一把創(chuàng)建的接口實(shí)例注入到 Common 層所需的地方。
// Common expect class LLMOperatorFactory { fun create(): LLMOperator } val sharedModule = module { // 從不同的 LLMOperatorFactory 創(chuàng)建出 Common 層所需的 LLMOperator single小結(jié): 我們通過(guò)一個(gè)小小的案例,領(lǐng)略到了 Kotlin 和 Swift 的深度交互。還借助 expect/actual 關(guān)鍵字與 Koin 的依賴注入,讓整體方案更流暢和自動(dòng)化,達(dá)到了在 KMP 的 Common 模塊調(diào)用 Android 和 iOS Native SDK 的目標(biāo)。{ get ().create() } } // Android actual class LLMOperatorFactory(private val context: Context){ actual fun create(): LLMOperator = LLMInferenceAndroidImpl(context) } val androidModule = module { // Android 注入 App 的 Context single { LLMOperatorFactory(androidContext()) } } // iOS actual class LLMOperatorFactory(private val llmInferenceDelegate: LLMOperatorSwift) { actual fun create(): LLMOperator = LLMOperatorIOSImpl(llmInferenceDelegate) } module { // iOS 注入 onStartup 函數(shù)傳入的 delegate single { LLMOperatorFactory(llmInferenceDelegate) } }
移植 UI 和 ViewModel
原項(xiàng)目里的 InferenceMode 已經(jīng)被上一節(jié)的 LLMOperator 所取代,因此我們拷貝除 Activity 的剩下 5 個(gè)類:
下面我們修改幾處代碼使 Jetpack Compose 的代碼可以方便的遷移到 Compose Multiplatform。
首先是外圍的 ViewModel,KMP 版本我在這里使用了 Voyage,因此替換為 ScreenModel。不過(guò)官方 ViewModel 的方案也在實(shí)驗(yàn)中了,請(qǐng)參考這個(gè)文檔。
// Android 版本 class ChatViewModel( private val inferenceModel: InferenceModel ) : ViewModel() {...} // KMP 版本,轉(zhuǎn)換 ViewModel 為 ScreenModel,并修改傳入對(duì)象 class ChatViewModel( private val llmOperator: LLMOperator ):ScreenModel{...}
Voyage https://github.com/adrielcafe/voyager
文檔 https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-viewmodel.html
相應(yīng)的 ViewModel 初始化方式也更改成 ScreenModel 的方法:
// Android 版本 @Composable internal fun ChatRoute( chatViewModel: ChatViewModel = viewModel( factory = ChatViewModel.getFactory(LocalContext.current.applicationContext) ) ) { ... ChatScreen(...) {...} } // KMP 版本,改成外部初始化后傳入 @Composable internal fun ChatRoute( chatViewModel: ChatViewModel ) { // 此處采用了默認(rèn)參數(shù)注入的方案,便于解耦。 // koinInject() 是 Koin 官方提供的針對(duì) Compose // 的 @Composable 函數(shù)注入的一個(gè)方法。 @Composable fun AiScreen(llmOperator:LLMOperator = koinInject()) { // 使用 ScreenModel 的 remember 方法 val chatViewModel = rememberScreenModel { ChatViewModel(llmOperator) } ... Column { ... Box(...) { if (showLoading) { ... } else { ChatRoute(chatViewModel) } } } }對(duì)應(yīng)的 ViewModel 內(nèi)部的 LLM 功能調(diào)用接口也要進(jìn)行替換:
// Android 版本 inferenceModel.generateResponseAsync(fullPrompt) inferenceModel.partialResults .collectIndexed { index, (partialResult, done) -> ... } // KMP 版本,把 Flow 的返回前置了,兼容了兩個(gè)平臺(tái)的 SDK 設(shè)計(jì) llmOperator.generateResponseAsync(fullPrompt) .collectIndexed { index, (partialResult, done) -> ... }
然后是 Compose Multiplatform 特定的資源加載方式,把 R 文件替換為 Res:
// Android 版本 Text(stringResource(R.string.chat_label)) // KMP 版本,該引用是使用插件從 xml 映射而來(lái) // (commonMain/composeResources/values/strings.xml) import mediapiper.app.generated.resources.chat_label ... Text(stringResource(Res.string.chat_label))
至此我們已經(jīng)完成了 ChatScreen ChatViewModel 的主頁(yè)面功能遷移。
最后是其他的幾個(gè)輕微改動(dòng):
LoadingScreen 我們?nèi)绶ㄅ谥苽魅?LLMOperator 進(jìn)行初始化 (替換原有 InferenceModel)。
ChatMessage 只需修改了 UUID 調(diào)用的一行 API 到原生實(shí)現(xiàn) (Kotlin 2.0.20 后就不需要了)。
ChatUiState 則完全不用動(dòng)。
剩下的就只有整體修改下 Log 庫(kù)的引用等小細(xì)節(jié)。
小結(jié): 倘若略去 Log、R 文件的引用替換以及 import 替換等,核心的修改其實(shí)僅十幾行,便能把整個(gè) UI 部分也跑起來(lái)了。
簡(jiǎn)單測(cè)試
那 Gemma 2B 的性能如何,我們看幾個(gè)簡(jiǎn)單的例子。此處主要使用三個(gè)版本的模型進(jìn)行測(cè)試,模型的定義在 me.xx2bab.mediapiper.llm.LLMOperator (模型在兩端部署請(qǐng)參考項(xiàng)目 README)。
gemma-2b-it-gpu-int4
gemma-2b-it-cpu-int4
gemma-2b-it-cpu-int8
其中:
it 指代一種變體,即 Instruction Tuned 模型,更適合聊天用途,因?yàn)樗鼈兘?jīng)過(guò)微調(diào)能更好地理解指令,并生成更準(zhǔn)確的回答。
int4/8 指代模型量化,即將模型中的浮點(diǎn)數(shù)轉(zhuǎn)換為低精度整數(shù),從而減小模型的大小和計(jì)算量以適配小型的本地設(shè)備例如手機(jī)。當(dāng)然,模型的精度和回答準(zhǔn)確度也會(huì)有一些下降。
CPU 和 GPU指針對(duì)的硬件平臺(tái),這方便了設(shè)備 GPU 較弱甚至沒(méi)有時(shí)可選擇 CPU 執(zhí)行。從下面的測(cè)試結(jié)果你會(huì)發(fā)現(xiàn)當(dāng)前移動(dòng)設(shè)備上 CPU 版本也常常會(huì)占優(yōu),因?yàn)槟P鸵?guī)模小、簡(jiǎn)單對(duì)話計(jì)算操作也不大,并且 Int 量化也有利于 CPU 的指令執(zhí)行。
首先我們測(cè)試一個(gè)簡(jiǎn)單的邏輯: "蘆筍是不是一種動(dòng)物"?可以看到下圖的 CPU 版本答案比兩個(gè) GPU (iOS 和 Android) 更合理。而下一個(gè)測(cè)試是翻譯答案為中文,則是三個(gè)嘗試都不太行。
接著我們提高了測(cè)試問(wèn)題的難度,讓它執(zhí)行區(qū)分動(dòng)植物的單詞分類: 不管是 GPU 或者 CPU 的版本都不錯(cuò)。
再次升級(jí)上個(gè)問(wèn)題,讓它用 JSON 的方式輸出答案,就出現(xiàn)明顯的問(wèn)題:
圖 1 沒(méi)有輸出完整的代碼片段,缺少了結(jié)尾的三個(gè)點(diǎn) ```。
圖二分類錯(cuò)誤,把山竹放到動(dòng)物,植物出現(xiàn)了兩次向日葵。
圖三同二的錯(cuò)誤,但這三次都沒(méi)有純輸出一個(gè) JSON,實(shí)際上還是不夠嚴(yán)格執(zhí)行作為 JSON Responder 的角色。
最后,這其實(shí)不是極限,如果我們使用 cpu-int8 的版本,則可以高準(zhǔn)確率地解答上面問(wèn)題。以及,如果把本 Demo 的 iOS 入口代碼發(fā)送給它分析,也能答的不錯(cuò)。
Gemma 1 的 2B 版本測(cè)試至此,我們發(fā)覺(jué)其推理效果還有不少進(jìn)步空間,勝在回復(fù)速度不錯(cuò)。而事實(shí)上 Gemma 2 的 2B 版本前不久已推出,并且據(jù)官方測(cè)試其綜合水平已超過(guò) GPT 3.5。這意味著在一臺(tái)小小的手機(jī)里,本地的推理已經(jīng)可以達(dá)到一年半前的主流模型效果??偨Y(jié)實(shí)現(xiàn)這個(gè)本地聊天 Demo 的遷移和測(cè)試,給了我們些一手的經(jīng)驗(yàn):
LLM 的 On-Device Model 發(fā)展非常迅速,而借助 Google 的一系列基礎(chǔ)設(shè)施可以讓第三方 Mobile App 開(kāi)發(fā)者也迅速地集成相關(guān)的功能,并跨越 Android 與 iOS 雙平臺(tái)。
觀望目前情況綜合判斷,LLM 的 On-Device Model 有望在今年達(dá)到初步可用狀態(tài),推理速度已經(jīng)不錯(cuò),準(zhǔn)確度還有待進(jìn)一步測(cè)試 (例如 Gemma 2 的 2B 版本 + Mediapipe)。
遵循 Android 團(tuán)隊(duì)目前的策略 "Kotlin First"并大膽使用 Compose,是頗具前景的——在基礎(chǔ)設(shè)施完備的情況下,一個(gè)聊天的小模塊僅寥寥數(shù)行修改即可遷移到 iOS。
-
Google
+關(guān)注
關(guān)注
5文章
1782瀏覽量
58496 -
移植
+關(guān)注
關(guān)注
1文章
392瀏覽量
28509 -
開(kāi)源
+關(guān)注
關(guān)注
3文章
3533瀏覽量
43292 -
iOS
+關(guān)注
關(guān)注
8文章
3399瀏覽量
152262 -
LLM
+關(guān)注
關(guān)注
1文章
316瀏覽量
640
原文標(biāo)題:【GDE 分享】移植 Mediapipe LLM Demo 到 Kotlin Multiplatform
文章出處:【微信號(hào):Google_Developers,微信公眾號(hào):谷歌開(kāi)發(fā)者】歡迎添加關(guān)注!文章轉(zhuǎn)載請(qǐng)注明出處。
發(fā)布評(píng)論請(qǐng)先 登錄
相關(guān)推薦
無(wú)法在OVMS上運(yùn)行來(lái)自Meta的大型語(yǔ)言模型 (LLM),為什么?
怎樣去使用MediaPipe的helloworld example呢
求助,鴻蒙移植kotlin代碼,需要將其轉(zhuǎn)換成java實(shí)現(xiàn)嗎?
求助,官方出的MESH DEMO怎么改成了Kotlin和JAVA混和了?
分析Kotlin和Java EE的關(guān)系

Kotlin的概述

使用Kotlin替代Java重構(gòu)AOSP應(yīng)用

bilisoleil-kotlin Kotlin版仿B站項(xiàng)目

將其Android應(yīng)用的Java代碼遷移到Kotlin
使用Mediapipe控制Gripper

Kotlin發(fā)布2023年路線圖:K2編譯器、完善教程文檔等
Kotlin的語(yǔ)法糖解析
Kotlin聲明式UI框架Compose Multiplatform支持iOS

由Java改為 Kotlin過(guò)程中遇到的坑

詳解Object Detection Demo的移植

評(píng)論