前言
在之前的开发中,已经实现了登陆页、首页、朋友圈等部分内容。在一篇文章中,我将使用 Jetpack Compose 实现聊天界面,介绍 Jetpack 库的 Room 组件的使用以及Room 结合 Paging 分页组件实现本地数据分页查询。
界面拆分
我将界面主要分为三部分,顶部标题 、聊天记录列表 和底部输入框部分,如下图:
聊天记录列表
聊天记录的内容主要包括当前用户的头像,好友的头像,对话内容和对话时间;当前用户的头像从个人信息获取,好友的头像从首页的对话列表获取,所以这里实体类不用管头像的事,只需要实现其他字段即可,在这里我将使用 Room 组件实现聊天记录的保存。
Jetpack Room 介绍
Jetpack Room 是Google官方在SQLite基础上封装的一款数据持久库,是Jetpack全家桶的一员,和Jetpack其他库有着可以高度搭配协调的天然优势。它抽象了SQLite,通过提供方便的API来查询数据库,并在编译时验证。并且可以使用SQLite的全部功能,同时拥有Java SQL查询生成器提供的类型安全。
引入 Room 相关依赖
kotlin
val room_version = "2.5.0"
implementation "androidx.room:room-ktx:$room_version"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
testImplementation("androidx.room:room-testing:$room_version")
implementation("androidx.room:room-paging:$room_version")
Entity类 - 聊天记录实体类 ChatSmsEntity 创建
less
@Entity(tableName = "chatsms")
data class ChatSmsEntity (
/**
* 主键,自增id
*/
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id")
var id: Long = 0,
/**
* 用户id
*/
@ColumnInfo(name = "userId")
var userId: Long = 0,
/**
* 消息内容
*/
@ColumnInfo(name = "message")
var message: String,
/**
* 消息内容类型,与枚举类MediaType对应
*/
@ColumnInfo(name = "mediaType")
var mediaType: Int = 1,
/**
* 消息类型,与枚举类MessageType对应
*/
@ColumnInfo(name = "messageType")
var messageType: Int = 0,
/**
* 创建时间
*/
@ColumnInfo(name = "createBy")
var createBy: Long = 0,
)
这里的实体类其实就是表的结构,表名为 chatsms ,主要使用 @Entity 注解和 @ColumnInfo 注解分别标识表名和字段名。
Dao接口 - ChatSmsDao创建
less
@Dao
interface ChatSmsDao {
/**
* 新增聊天记录
*/
@Insert
suspend fun insert(entity: ChatSmsEntity): Long
/**
* 删除指定聊天记录
*/
@Query("DELETE FROM chatsms WHERE id=:id")
suspend fun delete(id: Long)
/**
* 删除指定用户聊天记录
*/
@Query("DELETE FROM chatsms WHERE userId =:userId")
suspend fun deleteAllByUserId(userId: Long)
/**
* 删除所有聊天记录
*/
@Query("DELETE FROM chatsms")
suspend fun deleteAll()
/**
* 分页查询聊天记录
*/
@Query("SELECT * FROM chatsms WHERE userId =:userId ORDER BY id DESC")
fun getAllByPagingSource(userId: Long): PagingSource<Int, ChatSmsEntity>
}
这里主要使用注解 @Dao 标识接口,然后写CRUD的逻辑,除了代码中的 @Insert 和 @Query ,还有 @Delete 和 @Update 等常用注解,这里接口我主要使用了 新增聊天记录 和 分页查询聊天记录 ,在分页查询聊天记录时我使用了 PagingSource<Int, ChatSmsEntity> ,PagingSource是分页组件 Paging 的内容,在前面的文章中我有介绍。
枚举类 MediaType
scss
enum class MediaType(var value: Int) {
TEXT(1),
VIDEO(2),
IMAGE(3),
AUDIO(4);
}
枚举类 MessageType
scss
enum class MessageType(val value: Int) {
/**
* 接收的信息
*/
RECEIVE(0),
/**
* 发送的信息
*/
SEND(1)
}
RoomDatabase数据库类 - COMChatDataBase创建
kotlin
@Database(
entities = [ChatSmsEntity::class],
version = 1,
exportSchema = false
)
abstract class COMChatDataBase: RoomDatabase() {
abstract fun chatSmsDao(): ChatSmsDao
companion object {
val instance = Room.databaseBuilder(ComposeWechatApp.instance, COMChatDataBase::class.java, "compose_chat_db").build()
}
}
这里主要是用注解 @Database 标识关联的表(entities ),数据库版本号(version)等。
到这里,聊天记录保存的逻辑已经实现,我们测试新增一些数据,然后使用Android Studio的工具 App Inspection 查看,结果如下:
页面的实现
使用脚手架 Scaffold 来实现页面布局,其中 topBar 实现顶部标题,content 实现对话列表,bottomBar 实现底部输入框。
顶部标题的实现
ini
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
session.name,
maxLines = 1,
fontSize = 16.sp,
overflow = TextOverflow.Ellipsis,
color = Color(0xff000000)
)
},
actions = {
IconButton(
onClick = {
/* doSomething() */
}) {
Icon(
imageVector = Icons.Filled.MoreHoriz,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color(0xff000000)
)
}
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = Color(ContextCompat.getColor(context, R.color.nav_bg)),
scrolledContainerColor = Color(ContextCompat.getColor(context, R.color.nav_bg)),
navigationIconContentColor = Color.White,
titleContentColor = Color(ContextCompat.getColor(context, R.color.black_10)),
actionIconContentColor = Color(ContextCompat.getColor(context, R.color.black_10)),
),
navigationIcon = {
IconButton(
onClick = {
/* doSomething() */
}) {
Icon(
imageVector = Icons.Filled.ArrowBackIos,
contentDescription = null,
modifier = Modifier
.size(20.dp)
.clickable {
context.finish()
},
tint = Color(0xff000000)
)
}
}
)
},
这里包含返回图标,中间的好友名称以及右边的navigationIcon操作图标。
对话列表的实现
这里使用 LazyColumn 实现列表的展示,通过ViewModel来处理交互的逻辑,其中:
ChatViewModel的实现
ini
class ChatViewModel(userId: Long) : ViewModel() {
private val _userId: Long = userId
val chatSmsItems: Flow<PagingData<ChatSmsEntity>> =
Pager(PagingConfig(pageSize = 10, prefetchDistance = 1)) {
/** 查询表里的聊天记录 */
COMChatDataBase.instance.chatSmsDao().getAllByPagingSource(_userId)
}.flow
.flowOn(Dispatchers.IO)
.cachedIn(viewModelScope)
/**
* 模拟对方发来的随机对话
*/
private val _mockReceiveSms = listOf(
"你好啊,最近过得怎么样?",
"嗨,我挺好的,谢谢关心。最近工作有点忙,但也在努力调整自己的状态。",
"是啊,工作总是让人有些疲惫。你有什么放松的方法吗?",
"你对未来有什么规划吗?",
"其实我在考虑出国留学,但还在考虑中。你呢?有什么打算?",
"我打算创业,自己开一家咖啡店。希望能在未来几年内实现这个梦想",
"你觉得什么样的人最吸引你?",
"是啊,和有趣的人在一起总是让人感到快乐。我也很喜欢和有趣的人交朋友",
"我觉得不诚实、虚伪、不尊重他人的人最不吸引我。我觉得人与人之间的尊重和理解很重要",
"您能告诉我您的时间安排吗?"
)
/**
* 发送信息(保存到本地数据库)
*/
fun sendMessage (message: String, messageType: MessageType) {
val entity: ChatSmsEntity
if (messageType == MessageType.SEND) {
entity = ChatSmsEntity(
userId = _userId,
mediaType = MediaType.TEXT.value,
message = message,
messageType = messageType.value,
createBy = currentTimeMillis()
)
} else {
val random = Random()
/** 生成0-9之间的随机数*/
val index = random.nextInt(10)
entity = ChatSmsEntity(
userId = _userId,
mediaType = MediaType.TEXT.value,
message = _mockReceiveSms[index],
messageType = MessageType.RECEIVE.value,
createBy = currentTimeMillis()
)
}
viewModelScope.launch(Dispatchers.IO) {
val id = COMChatDataBase.instance.chatSmsDao().insert(entity)
println("id============$id")
}
}
}
这里的逻辑主要使用 Kotlin Flow 来处理分页查询的聊天记录数据以及保存聊天记录到本地数据库,因为没有接 IM 功能,所以只是使用模拟数据来模拟对方发送的信息。
列表的页面实现
ini
content = { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(ContextCompat.getColor(context, R.color.nav_bg))),
contentAlignment = Alignment.BottomEnd
) {
LazyColumn(
state = scrollState,
contentPadding = innerPadding,
modifier = Modifier.padding(start = 15.dp, end = 15.dp,bottom = 60.dp),
reverseLayout = true,
verticalArrangement = Arrangement.Top,
) {
items(lazyChatItems) {
it?.let {
MessageItemView(it, session)
}
}
lazyChatItems.apply {
when (loadState.append) {
is LoadState.Loading -> {
item { Loading() }
} else -> {}
}
}
}
}
}
这里需要说明的是列表是从底部向上 的,所以设置了容器Box的属性 contentAlignment = Alignment.BottomEnd(位于底部) ,LazyColumn的属性 reverseLayout = true(倒序)。
底部输入框部分的实现
ini
bottomBar = {
NavigationBar(
modifier = Modifier.height(60.dp),
containerColor = Color(ContextCompat.getColor(context, R.color.nav_bg)),
) {
Column(modifier = Modifier.fillMaxSize()) {
CQDivider()
Row(modifier = Modifier
.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PauseCircleOutline,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = Color(0xff000000)
)
}
Box(
modifier = Modifier
.padding(6.dp)
.fillMaxHeight()
.weight(6f)
) {
BasicTextField(
value = inputText,
onValueChange = {
inputText = it
},
textStyle = TextStyle(
fontSize = 16.sp
),
modifier = Modifier
.defaultMinSize(minHeight = 45.dp, minWidth = 280.dp)
.focusRequester(focusRequester),
decorationBox = { innerTextField->
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp))
.background(Color.White)
.padding(start = 6.dp),
contentAlignment = Alignment.CenterStart
) {
innerTextField()
}
},
/** 光标颜色*/
cursorBrush = SolidColor(Color(0xff5ECC71))
)
}
if (inputText == "") {
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (!sheetState.isVisible )Icons.Filled.TagFaces else Icons.Filled.BlurCircular,
contentDescription = null,
modifier = Modifier
.size(30.dp)
.click {
focusRequester.requestFocus()
scope.launch {
if (!sheetState.isVisible) {
keyboardController?.hide()
sheetState.show()
} else {
sheetState.hide()
keyboardController?.show()
}
}
},
tint = Color(0xff000000)
)
}
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.AddCircleOutline,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = Color(0xff000000)
)
}
} else {
Box(modifier = Modifier
.padding(
start = 10.dp,
top = 12.dp,
bottom = 12.dp,
end = 10.dp
)
.fillMaxHeight()
.weight(2f),
contentAlignment = Alignment.Center ) {
Text(
text = "发送",
fontSize = 15.sp,
color = Color.White,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xff5ECC71))
.padding(top = 4.dp)
.clickable {
viewModel.sendMessage(inputText, MessageType.SEND)
// 发送信息后滚动到最底部
scope.launch {
scrollState.scrollToItem(0)
inputText = ""
sheetState.hide()
/** 本人发送一条信息后保存一条对面回复模拟信息*/
delay(200)
viewModel.sendMessage(
"",
MessageType.RECEIVE
)
}
},
textAlign = TextAlign.Center
)
}
}
}
}
}
}
这里主要是一个文本输入框和左右的操作图标,输入框使用了组件BasicTextField,没有使用TextField和OutlinedTextField的原因是发现他们的内边距比较大,目前处理不了。除了实现文本输入外,这里还实现了表情包的选择弹窗。
表情包弹窗面板的实现
我这里使用了 emoji2 ,通过底部弹窗组件 ModalBottomSheetLayout 装载emoji布局,emoji2依赖引入的方式为:
arduino
implementation 'androidx.emoji2:emoji2-emojipicker:1.4.0-beta05'
EmojiPicker的实现
less
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun EmojiPicker(
modalBottomSheetState: ModalBottomSheetState,
onPicked: (emoji: String) -> Unit
) {
ModalBottomSheetLayout(
sheetState = modalBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp),
sheetContent = {
Column {
/** Spacer: 解决报错 java.lang.IllegalArgumentException:
* The initial value must have an associated anchor.
*/
Spacer(modifier = Modifier.height(1.dp))
Box(modifier = Modifier.fillMaxWidth().wrapContentHeight()) {
AndroidView(
factory = { context ->
EmojiPickerView(context)
},
modifier = Modifier.fillMaxWidth()
) { it ->
it.setOnEmojiPickedListener {
onPicked(it.emoji)
}
}
}
}
}
){}
}
看下表情包面板的实现效果
页面ChatScreen的全部代码为:
ini
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class,
ExperimentalComposeUiApi::class
)
@Composable
fun ChatScreen(viewModel: ChatViewModel, session: ChatSession) {
val context = LocalContext.current as Activity
val scrollState = rememberLazyListState()
var inputText by remember { mutableStateOf("") }
/** 聊天消息 */
val lazyChatItems = viewModel.chatSmsItems.collectAsLazyPagingItems()
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
rememberSystemUiController().setStatusBarColor(Color(ContextCompat.getColor(context, R.color.nav_bg)), darkIcons = true)
Surface(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.autoCloseKeyboard()
) {
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
session.name,
maxLines = 1,
fontSize = 16.sp,
overflow = TextOverflow.Ellipsis,
color = Color(0xff000000)
)
},
actions = {
IconButton(
onClick = {
/* doSomething() */
}) {
Icon(
imageVector = Icons.Filled.MoreHoriz,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color(0xff000000)
)
}
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = Color(ContextCompat.getColor(context, R.color.nav_bg)),
scrolledContainerColor = Color(ContextCompat.getColor(context, R.color.nav_bg)),
navigationIconContentColor = Color.White,
titleContentColor = Color(ContextCompat.getColor(context, R.color.black_10)),
actionIconContentColor = Color(ContextCompat.getColor(context, R.color.black_10)),
),
navigationIcon = {
IconButton(
onClick = {
/* doSomething() */
}) {
Icon(
imageVector = Icons.Filled.ArrowBackIos,
contentDescription = null,
modifier = Modifier
.size(20.dp)
.clickable {
context.finish()
},
tint = Color(0xff000000)
)
}
}
)
},
bottomBar = {
NavigationBar(
modifier = Modifier.height(60.dp),
containerColor = Color(ContextCompat.getColor(context, R.color.nav_bg)),
) {
Column(modifier = Modifier.fillMaxSize()) {
CQDivider()
Row(modifier = Modifier
.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PauseCircleOutline,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = Color(0xff000000)
)
}
Box(
modifier = Modifier
.padding(6.dp)
.fillMaxHeight()
.weight(6f)
) {
BasicTextField(
value = inputText,
onValueChange = {
inputText = it
},
textStyle = TextStyle(
fontSize = 16.sp
),
modifier = Modifier
.defaultMinSize(minHeight = 45.dp, minWidth = 280.dp)
.focusRequester(focusRequester),
decorationBox = { innerTextField->
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp))
.background(Color.White)
.padding(start = 6.dp),
contentAlignment = Alignment.CenterStart
) {
innerTextField()
}
},
/** 光标颜色*/
cursorBrush = SolidColor(Color(0xff5ECC71))
)
}
if (inputText == "") {
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (!sheetState.isVisible )Icons.Filled.TagFaces else Icons.Filled.BlurCircular,
contentDescription = null,
modifier = Modifier
.size(30.dp)
.click {
focusRequester.requestFocus()
scope.launch {
if (!sheetState.isVisible) {
keyboardController?.hide()
sheetState.show()
} else {
sheetState.hide()
keyboardController?.show()
}
}
},
tint = Color(0xff000000)
)
}
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.AddCircleOutline,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = Color(0xff000000)
)
}
} else {
Box(modifier = Modifier
.padding(
start = 10.dp,
top = 12.dp,
bottom = 12.dp,
end = 10.dp
)
.fillMaxHeight()
.weight(2f),
contentAlignment = Alignment.Center ) {
Text(
text = "发送",
fontSize = 15.sp,
color = Color.White,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xff5ECC71))
.padding(top = 4.dp)
.clickable {
viewModel.sendMessage(inputText, MessageType.SEND)
// 发送信息后滚动到最底部
scope.launch {
scrollState.scrollToItem(0)
inputText = ""
sheetState.hide()
/** 本人发送一条信息后保存一条对面回复模拟信息*/
delay(200)
viewModel.sendMessage(
"",
MessageType.RECEIVE
)
}
},
textAlign = TextAlign.Center
)
}
}
}
}
}
},
content = { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(ContextCompat.getColor(context, R.color.nav_bg))),
contentAlignment = Alignment.BottomEnd
) {
LazyColumn(
state = scrollState,
contentPadding = innerPadding,
modifier = Modifier.padding(start = 15.dp, end = 15.dp,bottom = 60.dp),
reverseLayout = true,
verticalArrangement = Arrangement.Top,
) {
items(lazyChatItems) {
it?.let {
MessageItemView(it, session)
}
}
lazyChatItems.apply {
when (loadState.append) {
is LoadState.Loading -> {
item { Loading() }
} else -> {}
}
}
}
EmojiPicker(
modalBottomSheetState = sheetState,
onPicked = { emoji ->
inputText += emoji
}
)
}
}
)
}
}
@Composable
fun MessageItemView(it: ChatSmsEntity, session: ChatSession) {
Box(
contentAlignment = if (it.messageType == MessageType.RECEIVE.value) Alignment.CenterStart else Alignment.CenterEnd,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(
top = 30.dp,
start = if (it.messageType == MessageType.RECEIVE.value) 0.dp else 40.dp,
end = if (it.messageType == MessageType.SEND.value) 0.dp else 40.dp
),
) {
Column(modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
) {
/*** 对话时间*/
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp),
contentAlignment = Alignment.Center
) {
Text(
text = toTalkTime(it.createBy),
fontSize = 12.sp,
color = Color(0xff888888)
)
}
/*** 对话信息*/
Row(modifier = Modifier
.wrapContentWidth()
.wrapContentHeight()
) {
/*** 他人头像(左边)*/
if(it.messageType == MessageType.RECEIVE.value) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.White)
.weight(1f)
) {
Image(
painter = rememberCoilPainter(request = session.avatar),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp))
)
}
}
Box(
modifier = Modifier
.weight(6f)
.wrapContentHeight(),
contentAlignment =
if (it.messageType == MessageType.RECEIVE.value) Alignment.TopStart
else Alignment.TopEnd
) {
/*** 尖角*/
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
contentAlignment = if (it.messageType == MessageType.RECEIVE.value) Alignment.TopStart else Alignment.TopEnd
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = null,
modifier = Modifier
.size(20.dp)
.rotate(if (it.messageType == MessageType.SEND.value) 0f else 180f),
tint = if (it.messageType == MessageType.RECEIVE.value) Color.White
else Color(0xffA9EA7A)
)
}
/*** 文本内容*/
Box(
modifier = Modifier
.padding(
start = if (it.messageType == MessageType.RECEIVE.value) 12.dp else 0.dp,
end = if (it.messageType == MessageType.RECEIVE.value) 0.dp else 12.dp,
)
.clip(RoundedCornerShape(4.dp))
.wrapContentWidth()
.wrapContentHeight(Alignment.CenterVertically)
.background(
if (it.messageType == MessageType.RECEIVE.value) Color.White else Color(
0xffA9EA7A
)
),
) {
Text(
modifier = Modifier.padding(8.dp),
text = it.message,
fontSize = 16.sp
)
}
}
/*** 本人头像(右边)*/
if(it.messageType == MessageType.SEND.value) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.White)
.weight(1f)
) {
Image(
painter = rememberCoilPainter(request = myAvatar),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp))
)
}
}
}
}
}
}
到这里,聊天页面的基本功能就实现了,我们看下效果
总结
在这一篇文章中,主要介绍了数据库 SQLite 基础上封装的一款数据持久库Jetpack Room 组件的使用,以及Room 结合 Paging 组件实现本地数据分页查询的功能。此外,还介绍了表情包 Emoji2 组件的使用。