Android 集成 ChatGPT

1 效果

2 技术栈

Timber: 日志打印工具(Github地址
Room:Jetpack包含的数据库(最全面的Room数据库指南
Splash Screens: 启动屏幕Android12支持(Android 12 SplashScreen API快速入门
Coil:kotlin图片加载框架(Github地址
Coroutine:kotlin协程(官方文档)
Retrofit:网络请求(Github地址

3 要点

3.1 Application初始化

class App : Application() {
    
    init {
        instance = this
    }
    companion object {
        private var instance : App? = null
        fun context() : Context {
            return instance!!.applicationContext
        }
    }
    
    override fun onCreate() {
        super.onCreate()
        Timber.plant(Timber.DebugTree())
    }
}

这里初始化了Timer

“Timber.plant(Timber.DebugTree())” 这个语句是 Android 中的一个日志框架 Timber 的语句,用于将 Timber 与一个 DebugTree 实例关联起来。
Timber 是一个 Android 日志框架,用于简化 Android 中的日志记录。它提供了一种更方便的方法来记录日志,而不需要繁琐的配置。
DebugTree 是 Timber 提供的一个简单的实现,它将日志信息打印到控制台,以便在开发期间进行调试。
通过调用 Timber.plant(Timber.DebugTree()),可以将 DebugTree 实例安装到 Timber 日志框架中,以便将日志记录到控制台。

3.2 启动页配置

<activity
        android:name=".view.MainActivity"
        android:theme="@style/Theme.Test.Splash"
        android:windowSoftInputMode="adjustResize"
        android:exported="true">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />

            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>

        <meta-data
            android:name="android.app.lib_name"
            android:value="" />
    </activity>

关联的主体为:

<style name="Theme.Test.Splash" parent="Theme.SplashScreen.IconBackground">
    <item name="windowSplashScreenAnimatedIcon">@drawable/splash_layout</item>
    <item name="windowSplashScreenBackground">@color/black</item>
    <!-- splash delay -->
    <item name="windowSplashScreenAnimationDuration">2000</item>
    <!-- splash icon back color
    <item name="windowSplashScreenIconBackgroundColor">@color/white</item> -->
    <item name="postSplashScreenTheme">@style/Theme.Test</item>
</style>

这里的Theme.SplashScreen.IconBackground主题需要引入这个三方库:

implementation 'androidx.core:core-splashscreen:1.0.0'

然后还需要在首页onCreate最前面,执行一下:

installSplashScreen得以支持

这里直接跳转MainActivity。

3.3 首页

数据定义:

private lateinit var binding : ActivityMainBinding
private val viewModel : MainViewModel by viewModels()
private var contentDataList = ArrayList<ContentEntity>()

ActivityMainBinding绑定的视图如下:

然后先走启动页,再设置布局:

installSplashScreen()

super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

这里再配置下Coil的图片加载器:

val imageLoader = this?.let {
    ImageLoader.Builder(it)
        .components {
            if (SDK_INT >= 28) {
                add(ImageDecoderDecoder.Factory())
            } else {
                add(GifDecoder.Factory())
            }
        }
        .build()
}
if (imageLoader != null) {
    Coil.setImageLoader(imageLoader)
}

binding.loading.visibility = View.INVISIBLE
binding.loading.load(R.drawable.loading3)

这里用了一个扩展ImageView的load函数。

3.4 配置LiveData监听

viewModel.contentList.observe(this, Observer {
    contentDataList.clear()
    for (entity in it) {
        contentDataList.add(entity)
    }
    setContentListRV()
})

上面是监听内容列表变更情况。如果有变化,就更新下contentDataList的全局变量。
看下setContentListRV()

private fun setContentListRV() {
    val contentAdapter = ContentAdapter(this, contentDataList)
    binding.RVContainer.adapter = contentAdapter
    binding.RVContainer.layoutManager = LinearLayoutManager(this).apply {
        stackFromEnd = true
    }

    viewModel.launch({
        delay(100)
        binding.SVContainer.fullScroll(ScrollView.FOCUS_DOWN)
    })

    contentAdapter.delChatLayoutClick = object : ContentAdapter.DelChatLayoutClick {
        override fun onLongClick(view : View, position: Int) {
            Timber.tag("TEST##").e("${contentDataList[position].id}")
            val builder = AlertDialog.Builder(this@MainActivity)
            builder.setTitle("清空")
                .setMessage("清空此条消息")
                .setPositiveButton("确定",
                    DialogInterface.OnClickListener { dialog, id ->
                        viewModel.deleteSelectedContent(contentDataList[position].id)
                    })
                .setNegativeButton("取消",
                    DialogInterface.OnClickListener { dialog, id ->
                    })
            builder.show()
        }
    }
}

这里每次都new了一个Adapter,然后走一个协程,滑动最底部。
同时也配置下长按点击事件,这里弹一个弹框,提示清空消息,如果要清空,就再走一次viewModel对数据进行删除。

第二个LiveData监听是:

 viewModel.deleteCheck.observe(this, Observer {
    if (it == true) {
        viewModel.getContentData()
    }
})

这里监听删除是否成功,如果成功,就重新获取一次数据。

第三个LiveData监听是:

 viewModel.gptInsertCheck.observe(this, Observer {
    if (it == true) {
        viewModel.getContentData()
        binding.loading.visibility = View.INVISIBLE
    }
})

插入数据监听,当用户发出消息成功后,需要插入数据库,插入后,这里更新列表,同时需要关闭加载动画。

3.5 其它点击事件

 binding.sendBtn.setOnClickListener {
    binding.loading.visibility = View.VISIBLE

    if (binding.EDView.text.toString().isEmpty()) {
        return@setOnClickListener
    }

    viewModel.postResponse(binding.EDView.text.toString())
    viewModel.insertContent(binding.EDView.text.toString(), 2) // 1: Gpt, 2: User
    binding.EDView.setText("")
    viewModel.getContentData()
}

发送消息事件,这里先展示loading状态,然后通过viewModel层走接口发送数据。
同时需要插入用户发送数据到本地数据库。
然后理解刷新数据。

 binding.backBtn.setOnClickListener {
    finish()
}

这里返回退出App。

3.6 消息Adapter

class ContentAdapter(val context : Context, private val dataSet : List<ContentEntity>) : RecyclerView.Adapter<ContentAdapter.ViewHolder>() {

    companion object {
        private const val Gpt = 1
        private const val User = 2
    }

    interface DelChatLayoutClick {
        fun onLongClick(view : View, position: Int)
    }
    var delChatLayoutClick : DelChatLayoutClick? = null

    inner class ViewHolder(view : View) : RecyclerView.ViewHolder(view) {
        val contentTV : TextView = view.findViewById(R.id.rvItemTV)
        val delChatLayout : ConstraintLayout = view.findViewById(R.id.chatLayout)
        val idHolder : TextView = view.findViewById(R.id.holdingId)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        if (viewType == Gpt) {
            val view = LayoutInflater.from(parent.context).inflate(R.layout.gpt_content_item, parent, false)
            return ViewHolder(view)
        } else {
            val view = LayoutInflater.from(parent.context).inflate(R.layout.user_content_item, parent, false)
            return ViewHolder(view)
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.contentTV.text = dataSet[position].content
        holder.idHolder.text = dataSet[position].id.toString()

        holder.delChatLayout.setOnLongClickListener { view ->
            delChatLayoutClick?.onLongClick(view, position)
            return@setOnLongClickListener true
        }

    }

    override fun getItemCount(): Int {
        return dataSet.size
    }

    override fun getItemViewType(position: Int): Int {
        return if (dataSet[position].gptOrUser == 1) { // Gpt
            Gpt
        } else {
            User
        }
    }

}

这里分两种Type,一种是GPT,一种是用户,展示不同item。

item数据:

@Entity(tableName = "ContentTable")
data class ContentEntity(

    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "id")
    var id : Int,
    @ColumnInfo(name = "Content")
    var content : String,
    @ColumnInfo(name = "gptOrUser")
    var gptOrUser : Int

)

这里是model层了,内容实体,有个int表示是人还是人工智能。

3.7 ViewModel层

class MainViewModel : ViewModel() {

    private val databaseRepository = DatabaseRepository()
    private val netWorkRepository = NetWorkRepository()

    private var _contentList = MutableLiveData<List<ContentEntity>>()
    val contentList : LiveData<List<ContentEntity>>
        get() = _contentList

    private var _deleteCheck = MutableLiveData<Boolean>(false)
    val deleteCheck : LiveData<Boolean>
        get() = _deleteCheck

    private var _gptInsertCheck = MutableLiveData<Boolean>(false)
    val gptInsertCheck : LiveData<Boolean>
        get() = _gptInsertCheck

这里继承ViewModel,然后建立2个仓库。

  • DatabaseRepository 数据仓库层
  • NetworkRepository 网络仓库层

然后有3个LiveData监听内容,删除,插入数据的监听。

协程封装:


/**
 * ViewModel扩展方法:启动协程
 * @param block 协程逻辑
 * @param onError 错误回调方法
 * @param onComplete 完成回调方法
 */
fun ViewModel.launch(
    block: suspend CoroutineScope.() -> Unit,
    onError: (e: Throwable) -> Unit = { _: Throwable -> },
    onComplete: () -> Unit = {}
) {
    viewModelScope.launch(
        CoroutineExceptionHandler { _, throwable ->
            run {
                if (throwable is Exception) {
                    onError(NetExceptionFilter.onFilter(throwable))
                } else {
                    onError(throwable)
                }
            }
        }
    ) {
        try {
            block.invoke(this)
        } finally {
            onComplete()
        }
    }
}

对ViewModel做了扩展,可以直接发起协程,并且内部捕获异常。
有个工具类可以对异常再继续处理:

object NetExceptionFilter {
    @JvmStatic
    fun onFilter(e: Exception): Exception {
        e.printStackTrace()
        if (e is HttpException) {
            return e
        }
        if (e is SocketTimeoutException) {
            return Exception("已超时")
        }
        if (e is UnknownHostException || e is SocketException) {
            return Exception("socket异常")
        }
        return if (e is IOException) {
            return Exception("io异常")
        } else Exception("其它异常")
    }
}

ok。然后具体看下viewModel层中的耗时任务。

第一个最重要的当然是发起调用GPT接口了:

fun postResponse(query : String) = launch({
    val jsonObject: JsonObject? = JsonObject().apply{
        // params
        addProperty("model", "text-davinci-003")
        addProperty("prompt", query)
        addProperty("temperature", 0)
        addProperty("max_tokens", 500)
        addProperty("top_p", 1)
        addProperty("frequency_penalty", 0.0)
        addProperty("presence_penalty", 0.0)
    }
    val response = netWorkRepository.postResponse(jsonObject!!)
    
    Timber.tag("TEST##").e("${response.choices.get(0)}")
    val gson = Gson()
    val tempjson = gson.toJson(response.choices.get(0))
    val tempgson = gson.fromJson(tempjson, GptText::class.java)
    Timber.tag("TEST##").e("${tempgson.text}")
    insertContent(tempgson.text.toString(), 1)
    
}, {
    Toast.makeText(App.context(), "${it.message}", Toast.LENGTH_SHORT).show()
    _gptInsertCheck.postValue(true)
})

这里配置GPT接口需要的参数定义,通过netWorkRepository发起请求,定义了接收数据的格式转换,然后拿到数据后,转换为GptText,并插入本地数据库。

有异常时,这里弹出一个toast。

第二个获取数据方法:

fun getContentData() = viewModelScope.launch(Dispatchers.IO) {
    _contentList.postValue(databaseRepository.getContentData())
    _deleteCheck.postValue(false)
    _gptInsertCheck.postValue(false)
}

这里通过向IO线程,调用databaseRepository层的获取数据库的方法,拿到最新数据。

第三个插入数据:

fun insertContent(content : String, gptOrUser : Int) = viewModelScope.launch(Dispatchers.IO) {
    databaseRepository.insertContent(content, gptOrUser)
    if (gptOrUser == 1) {
        _gptInsertCheck.postValue(true)
    }
}

这里也是调用了数据库层,插入数据。

第四个是删除数据:

fun deleteSelectedContent(id : Int) = viewModelScope.launch(Dispatchers.IO) {
    databaseRepository.deleteSelectedContent(id)
    _deleteCheck.postValue(true)
}

同样走数据库层删除数据。

3.8 Model层的数据库层

先看下数据库层:

class DatabaseRepository {

    private val context = App.context()
    private val database = ChatDatabase.getDatabase(context)

    fun getContentData() = database.contentDAO().getContentData()

    fun insertContent(content : String, gptOrUser : Int) = database.contentDAO().insertContent(ContentEntity(0, content, gptOrUser))

    fun deleteSelectedContent(id : Int) = database.contentDAO().deleteSelectedContent(id)

}

这是一个仓库类,声明了几个操作数据库的方法。

实体类声明:

@Entity(tableName = "ContentTable")
data class ContentEntity(

    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "id")
    var id : Int,
    @ColumnInfo(name = "Content")
    var content : String,
    @ColumnInfo(name = "gptOrUser")
    var gptOrUser : Int
)

这里id为唯一索引。表名叫做ContentTable。

DAO接口声明:

@Dao
interface ContentDAO {

    @Query("SELECT * FROM ContentTable")
    fun getContentData() : List<ContentEntity>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    fun insertContent(content : ContentEntity)

    @Query("DELETE FROM ContentTable WHERE id = :id")
    fun deleteSelectedContent(id : Int)

}

这里是操作数据库的底层方法。

数据库声明:

@Database(entities = [ContentEntity::class], version = 2)
abstract class ChatDatabase : RoomDatabase() {

    abstract fun contentDAO() : ContentDAO

    companion object {
        @Volatile
        private var INSTANCE : ChatDatabase? = null

        fun getDatabase(
            context : Context
        ) : ChatDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    ChatDatabase::class.java,
                    "chatDatabase"
                )
                    .fallbackToDestructiveMigration()
                    .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

这里就是初始化Room数据库了。

3.9 Model层的网络层

首先声明Api接口:

interface Apis {
    @Headers(
        "Content-Type:application/json",
        "Authorization:Bearer 自己的Key")
    @POST("v1/completions")
    suspend fun postRequest(
        @Body json : JsonObject
    ) : GptResponse

}

用了suspend,表示协程调用。
body发送的是Json对象,返回一个GptResponse对象。
这个是一个Json数组:

import com.google.gson.JsonArray

data class GptResponse (
    val choices : JsonArray
)

然后是Retrofit初始化:

object RetrofitInstance {
    private var okHttpClient = OkHttpClient
        .Builder()
        .connectTimeout(1, TimeUnit.MINUTES)
        .readTimeout(1, TimeUnit.MINUTES)
        .writeTimeout(1, TimeUnit.MINUTES)
        .build()

    private const val BASE_URL = "https://api.openai.com/"

    private val client = Retrofit
        .Builder()
        .baseUrl(BASE_URL)
        .client(okHttpClient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    fun getInstance() : Retrofit {
        return client
    }
}

new了一个OkHttpClient对象,同时将他作为参数传到Retrofit里面。

然后就是声明一个网络仓库来调用接口:

class NetWorkRepository {

    private val chatGPTClient = RetrofitInstance.getInstance().create(Apis::class.java)

    suspend fun postResponse(jsonData : JsonObject) = chatGPTClient.postRequest(jsonData)
}

4 总结

  • 总体来说,集成GPT很简单,本质上就是联调一个接口,使用Retrofit通过传参即可拿到返回数据。
  • 本篇文章主要是简单描述一个MVVM如何构造,主要是Activity和Adapter对应View层,自定义ViewModel层对应VM层,网络层和数据库层和实体 对应Model层。
  • 其次,学会Kotlin协程用法,这里ViewModel中可以使用viewModelScope 来调用launch函数,实现协程,最好是要捕获一下异常,保证多种可能性。
  • 另外,要了解Room数据库使用,通过执行协程,再调用数据库,拿到数据后再post发送给View层。

   转载规则


《Android 集成 ChatGPT》 Jason 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
Android 集成 高德地图API Android 集成 高德地图API
1 参考文档 Github源码地址:https://github.com/lilongweidev/GaodeMapDemo博主博客地址:Android 高德地图API(详细步骤+源码)7Android 高德地图API(详细步骤+源码)6A
2023-02-10
下一篇 
Android OpenGLES demo 学习之二 Android OpenGLES demo 学习之二
1 Demo2 展示一个蓝色三角形1.1 效果 1.2 首页传参val itemHelloTriangle2: MutableMap<String, Any?> = HashMap() itemHelloTriangl
2023-02-08
  目录