一、准备工作
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.TimeUnit
class 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.UUID
class ChatViewModel : ViewModel() {
private val arkService = ArkChatService()
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
private val _isSending = MutableStateFlow<Boolean>(false)
val isSending: StateFlow<Boolean> = _isSending
init {
// 初始化时检查并添加欢迎消息
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 + userMessage
try {
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.ShapeTextView
android: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"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<ImageView
android:id="@+id/btnRetry"
android:layout_width="20dp"
android:layout_height="20dp"
android:visibility="gone"
android:src="@mipmap/push_fail"
android:layout_marginStart="4dp"/>
<TextView
android: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">
<ImageView
android: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.ShapeTextView
android: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.CoordinatorLayout
xmlns: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.ShapeView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:shape_solidColor="#FFF2F7FF"/>
<com.hjq.shape.layout.ShapeFrameLayout
android: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">
<ImageView
android:id="@+id/fishImg"
android:layout_width="@dimen/dp30"
android:layout_height="@dimen/dp30"
android:src="@drawable/arrow_icon"/>
<TextView
android: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.ShapeView
android: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.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="72dp"
android:layout_marginTop="85dp"/>
<com.hjq.shape.layout.ShapeLinearLayout
android: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.ShapeEditText
android: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" />
<ImageView
android: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顶到上面去
<activity
android: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.RecyclerView
class MessageAdapter(
private val listener: (String) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val messages = mutableListOf<ChatMessage>()
companion object {
private const val TYPE_USER = 0
private const val TYPE_AI = 1
}
override fun getItemViewType(position: Int): Int =
when (messages[position].role) {
"user" -> TYPE_USER
"assistant" -> TYPE_AI
else -> 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.size
fun 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.content
when (message.status) {
MessageStatus.SENDING -> {
binding.tvStatus.text = "发送中..."
binding.btnRetry.visibility = View.GONE
}
MessageStatus.SUCCESS -> {
binding.tvStatus.text = "发送成功"
binding.btnRetry.visibility = View.VISIBLE
binding.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.launch
class ConsultActivity : AppCompatActivity() {
private val viewModel: ChatViewModel by viewModels()
private lateinit var mAdapter: MessageAdapter
private 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()
}
// 初始化RecyclerView
recyclerView.apply {
//ai消息太长时,adapter绘制item时可能由于时间太短绘制不出来,所以加下面的代码
setItemViewCacheSize(20) // 增加缓存
setHasFixedSize(false) // 允许动态大小
isDrawingCacheEnabled = true
drawingCacheQuality = View.DRAWING_CACHE_QUALITY_HIGH
layoutManager = 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.height
val keypadHeight = screenHeight - rect.bottom
if (keypadHeight > screenHeight * 0.15) { // 键盘显示
binding.recyclerView.post {
val lastPosition = (binding.recyclerView.adapter?.itemCount ?: 0) - 1
if (lastPosition >= 0) {
binding.recyclerView.smoothScrollToPosition(lastPosition)
}
}
}
}
}
}