一、准备工作
1、注册火山引擎账号
2、在控制台中生成api_key
3、开通需要的模型
这里开通的是这个模型
后续需要获取这个模型ID,例如上面这个模型ID为“doubao-1-5-pro-256k-250115 ”,模型ID可以在开通后在下面图片位置找到:
二、代码部分
1、消息相关bean对象
//第一个bean
data class Usage(val prompt_tokens: Int,val completion_tokens: Int,val total_tokens: Int
)//第二个bean里面
data class ChatMessage(var content: String,val role: String, // "user" or "assistant" 用户消息 AI消息 val status: MessageStatus = MessageStatus.SUCCESS,val id: String = UUID.randomUUID().toString() // 确保有默认值
) {// 添加安全的copy方法fun safeCopy(content: String = this.content,role: String = this.role,status: MessageStatus = this.status,id: String = this.id) = copy(content, role, status, id)
}enum class MessageStatus {SENDING, // 发送中SUCCESS, // 发送成功FAILED // 发送失败
}//第三个bean
data class ChatRequest(val messages: List<ChatMessage>, //消息列表val model: String = "doubao-1-5-pro-256k-250115", //填入自己开通的模型型号val stream: Boolean = false //是否流式输出
)//第四个bean
data class Choice(val message: ChatMessage,val index: Int,val finish_reason: String?
)//第五个bean
data class ChatResponse(val choices: List<Choice>?,val usage: Usage?,
)
2、接口对接类
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import sqmrr.flssbbb.cn.beans.ChatMessage
import sqmrr.flssbbb.cn.beans.ChatRequest
import sqmrr.flssbbb.cn.beans.ChatResponse
import java.util.concurrent.TimeUnitclass ArkChatService(private val apiKey: String = "控制台创建的APIKEY") {private val client = OkHttpClient.Builder().connectTimeout(60, TimeUnit.SECONDS) // 连接超时.readTimeout(60, TimeUnit.SECONDS) // 读取超时.writeTimeout(60, TimeUnit.SECONDS) // 写入超时.build()private val jsonMediaType = "application/json; charset=utf-8".toMediaType()private val baseUrl = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"suspend fun sendChatRequest(messages: List<ChatMessage>,model: String = "开通的模型名称"): ChatResponse {return withContext(Dispatchers.IO) {val requestBody = ChatRequest(messages = messages,model = model,stream = false).toJson().toRequestBody(jsonMediaType)val request = Request.Builder().url(baseUrl).addHeader("Content-Type", "application/json").addHeader("Authorization", "Bearer $apiKey").post(requestBody).build()val response = client.newCall(request).execute()if (!response.isSuccessful) {throw Exception("Request failed: ${response.code} - ${response.message}")}response.body?.string()?.fromJson<ChatResponse>()?: throw Exception("Empty response body")}}
}// 扩展函数用于JSON序列化/反序列化
inline fun <reified T> T.toJson(): String = Gson().toJson(this)
inline fun <reified T> String.fromJson(): T = Gson().fromJson(this, T::class.java)fun ChatResponse.getFirstMessageContent(): String? {return this.choices?.firstOrNull()?.message?.content
}
2、创建消息处理的viewmodel类
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import sqmrr.flssbbb.cn.beans.ChatMessage
import sqmrr.flssbbb.cn.beans.MessageStatus
import sqmrr.flssbbb.cn.utils.ArkChatService
import java.util.UUIDclass ChatViewModel : ViewModel() {private val arkService = ArkChatService()private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())val messages: StateFlow<List<ChatMessage>> = _messagesprivate val _error = MutableStateFlow<String?>(null)val error: StateFlow<String?> = _errorprivate val _isSending = MutableStateFlow<Boolean>(false)val isSending: StateFlow<Boolean> = _isSendinginit {// 初始化时检查并添加欢迎消息if (_messages.value.isEmpty()) {addWelcomeMessage()}}//刚开始添加一条ai的介绍消息private fun addWelcomeMessage() {val welcomeMessage = ChatMessage(content = "Hi,我是**,为您提供*****服务!", role = "assistant",status = MessageStatus.SUCCESS)_messages.value = _messages.value + welcomeMessage}fun addMessage(message: ChatMessage) {_messages.value = _messages.value + message}fun sendMessage(userInput: String) {if (_isSending.value) return // 直接返回,不做任何提示viewModelScope.launch {_isSending.value = true// 添加用户消息(初始状态为SENDING)val userMessage = ChatMessage(content = userInput,role = "user",status = MessageStatus.SENDING)_messages.value = _messages.value + userMessagetry {val response = arkService.sendChatRequest(filterMessage(_messages.value.toList()))// 更新用户消息状态为成功updateMessageStatus(userMessage.id, MessageStatus.SUCCESS)// 添加AI回复response.choices?.firstOrNull()?.message?.let { aiMessage ->aiMessage.content = aiMessage.content.replace("*","").replace ("#","")_messages.value = _messages.value + aiMessage.safeCopy(role = "assistant",status = MessageStatus.SUCCESS,id = UUID.randomUUID().toString())}} catch (e: Exception) {// 更新用户消息状态为失败updateMessageStatus(userMessage.id, MessageStatus.FAILED)_error.value = "发送失败: ${e.message}"} finally {_isSending.value = false}}}fun retryMessage(messageId: String) {viewModelScope.launch {// 更新状态为发送中updateMessageStatus(messageId, MessageStatus.SENDING)try {val response = arkService.sendChatRequest(_messages.value.toList())// 更新状态为成功updateMessageStatus(messageId, MessageStatus.SUCCESS)// 添加AI回复response.choices?.firstOrNull()?.message?.let { aiMessage ->_messages.value = _messages.value + aiMessage.safeCopy(status = MessageStatus.SUCCESS,id = UUID.randomUUID().toString() // 确保新消息有ID)}} catch (e: Exception) {updateMessageStatus(messageId, MessageStatus.FAILED)_error.value = "重试失败: ${e.message}"}}}/*** 检查是否存在至少两条AI答复,并返回最后一条* @return 最后一条AI答复,如果不足两条则返回null*/fun getLastAiReplyIfExistsTwo(): ChatMessage? {val aiReplies = _messages.value.filter { it.role == "assistant" }return if (aiReplies.size >= 2) {aiReplies.last()} else {null}}// 更新消息状态的方法private fun updateMessageStatus(messageId: String, status: MessageStatus) {_messages.value = _messages.value.map { message ->if (message.id == messageId) message.safeCopy(status = status) else message}}fun clearError() {_error.value = null}fun filterMessage(messageList : List<ChatMessage>): List<ChatMessage>{return messageList.filter { it.role == "assistant" || it.role == "user"}}
}
3、写一个聊天界面
(1)user消息Item的布局 item_message_user
有些控件是第三方的,只为了实现效果,自己改成原生的就行,比如shapeTextView 就是textView,或者导入这个控件库,贼拉好用
//第三方控件库implementation 'com.github.getActivity:ShapeView:9.3'implementation 'com.github.getActivity:ShapeDrawable:3.2'
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="match_parent"android:layout_height="wrap_content"xmlns:app="http://schemas.android.com/apk/res-auto"android:orientation="vertical"android:paddingVertical="@dimen/dp7"android:paddingEnd="@dimen/dp14"android:paddingStart="@dimen/dp16"android:gravity="end"><com.hjq.shape.view.ShapeTextViewandroid:id="@+id/tvContent"android:layout_width="wrap_content"android:layout_height="wrap_content"android:paddingHorizontal="12dp"android:paddingVertical="@dimen/dp15"android:layout_marginStart="@dimen/dp12"android:textSize="@dimen/sp12"android:textColor="@color/black"app:shape_shadowColor="#1ACECECE"app:shape_shadowSize="@dimen/dp2"app:shape_solidColor="@color/white"app:shape_radius="@dimen/dp5"/><LinearLayoutandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:orientation="horizontal"android:gravity="center"><ImageViewandroid:id="@+id/btnRetry"android:layout_width="20dp"android:layout_height="20dp"android:visibility="gone"android:src="@mipmap/push_fail"android:layout_marginStart="4dp"/><TextViewandroid:id="@+id/tvStatus"android:layout_width="wrap_content"android:layout_height="wrap_content"android:textSize="12sp"android:textColor="#FF3785FA"/></LinearLayout>
</LinearLayout>
(2)ai消息Item的布局 item_message_ai
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"android:layout_width="match_parent"android:layout_height="wrap_content"android:orientation="horizontal"android:paddingHorizontal="@dimen/dp14"android:paddingVertical="@dimen/dp7"><ImageViewandroid:id="@+id/tvRole"android:layout_width="@dimen/dp35"android:layout_height="@dimen/dp35"android:layout_marginTop="@dimen/dp7"android:src="@mipmap/ai_head" /><com.hjq.shape.view.ShapeTextViewandroid:id="@+id/tvContent"android:layout_width="wrap_content"android:layout_height="wrap_content"android:layout_marginStart="@dimen/dp12"android:paddingHorizontal="12dp"android:paddingVertical="@dimen/dp15"android:textSize="@dimen/sp12"android:textColor="@color/black"android:maxLines="100"android:ellipsize="end"android:lineSpacingMultiplier="1.2"android:importantForAccessibility="no"android:textIsSelectable="false"android:layerType="hardware"app:shape_shadowColor="#1ACECECE"app:shape_shadowSize="@dimen/dp2"app:shape_solidColor="@color/white"app:shape_radius="@dimen/dp5"/>
</LinearLayout>
(3)聊天界面activity布局
<androidx.coordinatorlayout.widget.CoordinatorLayoutxmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"android:id="@+id/content"android:layout_width="match_parent"android:layout_height="match_parent"android:fitsSystemWindows="true"><com.hjq.shape.view.ShapeViewandroid:layout_width="match_parent"android:layout_height="match_parent"app:shape_solidColor="#FFF2F7FF"/><com.hjq.shape.layout.ShapeFrameLayoutandroid:id="@+id/titleFl"android:layout_width="match_parent"android:layout_height="wrap_content"android:paddingHorizontal="@dimen/dp15"android:paddingTop="@dimen/dp35"android:paddingBottom="@dimen/dp10"app:shape_solidColor="@color/white"><ImageViewandroid:id="@+id/fishImg"android:layout_width="@dimen/dp30"android:layout_height="@dimen/dp30"android:src="@drawable/arrow_icon"/><TextViewandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:layout_gravity="center"android:text="AI对话"android:textColor="@color/black"android:textSize="@dimen/sp18"android:textStyle="bold" /></com.hjq.shape.layout.ShapeFrameLayout><com.hjq.shape.view.ShapeViewandroid:layout_width="match_parent"android:layout_height="@dimen/dp2"app:layout_constraintTop_toBottomOf="@+id/titleFl"app:shape_solidGradientStartColor="@color/white"app:shape_solidGradientEndColor="#1AB3B3B3"app:shape_solidGradientOrientation="topToBottom"/><!-- 消息列表 --><androidx.recyclerview.widget.RecyclerViewandroid:id="@+id/recyclerView"android:layout_width="match_parent"android:layout_height="match_parent"android:paddingBottom="72dp"android:layout_marginTop="85dp"/><com.hjq.shape.layout.ShapeLinearLayoutandroid:layout_width="match_parent"android:layout_height="wrap_content"android:layout_gravity="bottom"android:layout_marginHorizontal="@dimen/dp15"android:gravity="center_vertical"android:orientation="horizontal"android:padding="@dimen/dp5"app:layout_constraintBottom_toBottomOf="parent"app:shape_radius="@dimen/dp5"app:shape_strokeColor="#FFDDDDDD"app:shape_strokeSize="@dimen/dp1"android:layout_marginBottom="@dimen/dp10"><com.hjq.shape.view.ShapeEditTextandroid:id="@+id/etMessage"android:layout_width="match_parent"android:layout_height="wrap_content"android:layout_weight="1"android:hint="请输入"android:paddingVertical="0dp"android:textColor="@color/black"android:textColorHint="#FF999999"android:textSize="13sp" /><ImageViewandroid:id="@+id/btnSend"android:layout_width="30dp"android:layout_height="30dp"android:padding="@dimen/dp5"android:src="@mipmap/seed_icon" /></com.hjq.shape.layout.ShapeLinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
(4)AndroidManifest中给该activity添加属性
添加这个属性是为了在底部editView输入时,软键盘可以将editView顶到上面去
<activityandroid:name=".ui.activity.ConsultActivity"android:windowSoftInputMode="adjustResize|stateHidden" />
(5)创建Adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerViewclass MessageAdapter(private val listener: (String) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {private val messages = mutableListOf<ChatMessage>()companion object {private const val TYPE_USER = 0private const val TYPE_AI = 1}override fun getItemViewType(position: Int): Int =when (messages[position].role) {"user" -> TYPE_USER"assistant" -> TYPE_AIelse -> TYPE_USER}override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {return when (viewType) {TYPE_USER -> UserMessageViewHolder(ItemMessageUserBinding.inflate(LayoutInflater.from(parent.context), parent, false))TYPE_AI -> AiMessageViewHolder(ItemMessageAiBinding.inflate(LayoutInflater.from(parent.context), parent, false)}}override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {val message = messages[position]when (holder) {is UserMessageViewHolder -> holder.bind(message, listener)is AiMessageViewHolder -> holder.bind(message)}}override fun getItemCount() = messages.sizefun submitList(newMessages: List<ChatMessage>) {messages.clear()messages.addAll(newMessages)notifyDataSetChanged()}inner class UserMessageViewHolder(private val binding: ItemMessageUserBinding) : RecyclerView.ViewHolder(binding.root) {fun bind(message: ChatMessage, onRetry: (String) -> Unit) {binding.tvContent.text = message.contentwhen (message.status) {MessageStatus.SENDING -> {binding.tvStatus.text = "发送中..."binding.btnRetry.visibility = View.GONE}MessageStatus.SUCCESS -> {binding.tvStatus.text = "发送成功"binding.btnRetry.visibility = View.VISIBLEbinding.btnRetry.setImageResource(R.mipmap.push_success)}MessageStatus.FAILED -> {binding.tvStatus.text = "发送失败"binding.btnRetry.setImageResource(R.mipmap.push_fail)binding.btnRetry.setOnClickListener { onRetry(message.id) }}}}}inner class AiMessageViewHolder(private val binding: ItemMessageAiBinding) : RecyclerView.ViewHolder(binding.root) {fun bind(message: ChatMessage) {// 启用硬件层缓存binding.tvContent.setLayerType(View.LAYER_TYPE_HARDWARE, null)// 设置文本binding.tvContent.text = message.content}}
}
(6)最关键的activity逻辑代码
import android.graphics.Rect
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launchclass ConsultActivity : AppCompatActivity() {private val viewModel: ChatViewModel by viewModels()private lateinit var mAdapter: MessageAdapterprivate binding by lazy{ActivityConsultBinding.inflate(inflater, container, false)}public override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)enableEdgeToEdge()setContentView(binding.root)//解决enableEdgeToEdge与fitsSystemWindows= false时的冲突ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { view, insets ->val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())binding.content.updatePadding(top = -systemBars.top)insets}setupKeyboardBehavior()// 初始化Adapter时传入重试回调mAdapter = MessageAdapter { message ->viewModel.sendMessage(message)}binding.apply {fishImg.setOnClickListener {finish()}// 初始化RecyclerViewrecyclerView.apply {//ai消息太长时,adapter绘制item时可能由于时间太短绘制不出来,所以加下面的代码setItemViewCacheSize(20) // 增加缓存setHasFixedSize(false) // 允许动态大小isDrawingCacheEnabled = truedrawingCacheQuality = View.DRAWING_CACHE_QUALITY_HIGHlayoutManager = LinearLayoutManager(context)adapter = mAdapter}// 发送按钮点击事件btnSend.setOnClickListener {sendMessage()}etMessage.setOnEditorActionListener { _, actionId, _ ->if (actionId == EditorInfo.IME_ACTION_SEND) {sendMessage()true} else false}}// 观察消息列表lifecycleScope.launch {viewModel.messages.collect { messages ->mAdapter.submitList(messages)if (messages.size >2){binding.recyclerView.scrollToPosition(messages.size - 2)}}}// 观察错误lifecycleScope.launch {viewModel.error.collect { error ->error?.let {Toast.makeText(this@ConsultActivity, it, Toast.LENGTH_SHORT).show()viewModel.clearError()}}}// 观察发送状态,控制按钮可用性lifecycleScope.launch {viewModel.isSending.collect { isSending ->this@ConsultActivity.runOnUiThread {binding.btnSend.isEnabled = !isSending}}}}private fun sendMessage() {val message = binding.etMessage.text.toString().trim()if (message.isNotEmpty() && !viewModel.isSending.value) {viewModel.sendMessage(message)binding.etMessage.text?.clear()}}private fun setupKeyboardBehavior() {binding.root.viewTreeObserver.addOnGlobalLayoutListener {val rect = Rect()binding.root.getWindowVisibleDisplayFrame(rect)val screenHeight = binding.root.heightval keypadHeight = screenHeight - rect.bottomif (keypadHeight > screenHeight * 0.15) { // 键盘显示binding.recyclerView.post {val lastPosition = (binding.recyclerView.adapter?.itemCount ?: 0) - 1if (lastPosition >= 0) {binding.recyclerView.smoothScrollToPosition(lastPosition)}}}}}}