目录
- 初始化和依赖
- 存储库
- 视图模型
- 导航
- 屏幕
初始化和依赖
除了所有 Jetpack Compose 依赖项之外,我们还需要用于依赖项注入的 Hilt、用于图像加载的 Coil 以及用于聊天功能的 Amity。
implementation 'com.github.AmityCo.Amity-Social-Cloud-SDK-Android:amity-sdk:x.y.z''com.github.AmityCo.Amity-Social-Cloud-SDK-Android:amity-sdk:x.y.z'
implementation 'androidx.hilt:hilt-navigation-compose:x.y.z'
implementation 'com.google.dagger:hilt-android:x.y.z'
implementation 'com.google.dagger:hilt-compiler:x.y.z'
implementation 'com.google.dagger:hilt-android-compiler:x.y.z'
implementation 'com.google.dagger:hilt-android-testing:x.y.z'
implementation 'com.google.dagger:hilt-android-gradle-plugin:x.y.z'
implementation 'io.coil-kt:coil-compose:x.y.z'
登录/注册功能到位后,我们将在主要活动中初始化 Amity 的 SDK。
@HiltAndroidApp
class MyApplication : Application () {
override fun onCreate () {
super .onCreate()
AmityCoreClient.setup(
apiKey = "YOUR_API_KEY" ,
endpoint = AmityEndpoint.EU
)
}
}
存储库
然后,我们将创建 ChatsRepository 及其实现,其中我们将添加核心功能:获取所有频道(也称为聊天)、创建新频道、按 id 获取频道、获取频道的所有消息,以及当然,发布一条新消息。
interface ChatRepository {
val chats: Flow<PagingData<AmityChannel>>
fun createChannel (user: User , onError: ( Throwable ) -> Unit ) : Flow<AmityChannel>
fun getChannel (id: String , onError: ( Throwable ) -> Unit ) : Flow<AmityChannel>
fun getHistory (id: String , onError: ( Throwable ) -> Unit ) : Flow<PagingData<AmityMessage>>
suspend fun postMessage (
channelId: String ,
msg: String ,
onError: ( Throwable ) -> Unit
)
}
class RemoteChatsRepository @Inject constructor() : ChatRepository {
// initialize Amity
val amityChannelRepo = AmityChatClient.newChannelRepository()
val amityMessageRepo = AmityChatClient.newMessageRepository()
init {
AmityCoreClient.registerPushNotification()
}
override val chats: Flow<PagingData<AmityChannel>> =
amityChannelRepo.getChannels().all().build().query().asFlow()
override fun createChannel(user: User, onError: (Throwable) -> Unit) =
amityChannelRepo.createChannel()
.conversation(userId = user.uid)
.build()
.create()
.toFlowable().asFlow()
.catch {
Log.e(
"ChatRepository",
"createChannel exception: ${it.localizedMessage}",
it
)
onError(it)
}
override fun getChannel(id: String, onError: (Throwable) -> Unit) = amityChannelRepo.getChannel(id).asFlow()
.catch {
Log.e(
"ChatRepository",
"getChannel exception: ${it.localizedMessage}",
it
)
onError(it)
}
override fun getHistory(id: String, onError: (Throwable) -> Unit) =
amityMessageRepo.getMessages(subChannelId = id).build().query()
.asFlow()
.catch {
Log.e(
"ChatRepository",
"getHistory exception: ${it.localizedMessage}",
it
)
onError(it)
}
override suspend fun postMessage(
channelId: String,
msg: String,
onError: (Throwable) -> Unit
) {
try {
amityMessageRepo.createMessage(subChannelId = channelId).with().text(text = msg).build().send().subscribe()
} catch (e: Exception) {
Log.e("ChatRepository", "postMessage exception: ${e.localizedMessage}", e)
onError(e)
}
}
}
视图模型
在本教程中,我们将使用 MVVM 架构,所以接下来让我们构建我们的 ViewModel!我们需要两个 ViewModel;一个用于显示包含我们所有聊天的列表的屏幕,另一个用于消息传递屏幕。
@HiltViewModel
class ChatsViewModel @Inject constructor(
chatRepository: ChatRepository
) : ViewModel() {
val uiState: StateFlow<ChatsUiState> = chatRepository.chats
.cachedIn(viewModelScope)
.map { Success(data = flowOf(it)) }
.catch { Error(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Loading)
}
sealed interface ChatsUiState {
object Loading : ChatsUiState
data class Error(val throwable: Throwable) : ChatsUiState
data class Success(val data: Flow<PagingData<AmityChannel>>) : ChatsUiState
}
@HiltViewModel
class ConversationViewModel @Inject constructor(
val chatRepository: ChatRepository,
val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<ConversationUiState>(ConversationUiState.Loading)
val uiState: StateFlow<ConversationUiState> = _uiState
val currentUserId = authRepository.currentUserId
suspend fun getConversation(id: String) = chatRepository.getChannel(id, onError = {
_uiState.value = ConversationUiState.Error(it)
})
.collect {
_uiState.value = ConversationUiState.Success(it)
}
fun getHistory(id: String) = chatRepository.getHistory(id).cachedIn(viewModelScope)
fun sendMessage(channelId: String, msg: String, onError: (Throwable) -> Unit) =
viewModelScope.launch {
chatRepository.postMessage(
channelId = channelId,
msg = msg,
onError = onError
)
}
}
sealed interface ConversationUiState {
object Loading : ConversationUiState
data class Error(val throwable: Throwable) : ConversationUiState
data class Success(val data: AmityChannel) : ConversationUiState
}
在这里,ConversationUiState与聊天历史记录无关,因为即使我们无法检索以前的消息,我们也决定显示聊天记录。如果我们根本不想在发生错误时显示聊天,我们可以轻松地将这两者结合起来,如下所示。
suspend fun getConversation(id: String) {
val conversation = chatRepository.getChannel(id)
val history = chatRepository.getHistory(id)
return conversation.zip(history) { _conversation, _history ->
Conversation(_conversation, _history)
}.catch { _uiState.value = ConversationUiState.Error(it) }.collect{
_uiState.value = ConversationUiState.Success()
}
}
导航
现在我们已经准备好开始 UI 级别了!🤩
首先,我们将从导航开始,它将成为应用程序中可组合项的入口点。
@Composable
fun MainNavigation(
modifier: Modifier,
snackbarHostState: SnackbarHostState,
viewModel: MainViewModel = hiltViewModel()
) {
val navController = rememberNavController()
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
LaunchedEffect(lifecycleOwner) {
// Connectivity & login status monitoring code
// ...
}
NavHost(navController = navController, startDestination = Route.Loading.route) {
composable(Route.Loading.route) { LoadingScreen(...) }
composable(Route.UsersList.route) { UsersScreen(..) }
composable(Route.Login.route) { LoginScreen(...) }
composable(Route.ChatsList.route) {
ChatsScreen(
modifier = modifier,
navigateToUsers = { navController.navigate(Route.UsersList.route) },
onError = { showSnackbar(scope, snackbarHostState, it) },
navigateToConversation = { conversationId ->navController.navigate(Route.Conversation.createRoute(conversationId)) })
}
composable(Route.Conversation.route) { backStackEntry ->
ConversationScreen(
modifier = modifier,
onError = { showSnackbar(scope, snackbarHostState, it) },
navigateBack = { navController.navigate(Route.ChatsList.route) { popUpTo(0) } },
backStackEntry.arguments?.getString(Route.Conversation.ARG_CHANNEL_ID)
)
}
}
}
屏幕
我们终于可以构建我们的两个屏幕了。我们的频道列表ChatsUiState作为 PagingData 对象传入;因此我们将使用该LazyColumn布局。
@Composable
fun ChatsScreen(
modifier: Modifier,
navigateToUsers: () -> Unit,
onError: (String) -> Unit,
navigateToConversation: (String) -> Unit,
viewModel: ChatsViewModel = hiltViewModel()
) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
val uiState by produceState<ChatsUiState>(
initialValue = ChatsUiState.Loading,
key1 = lifecycle,
key2 = viewModel
) {
lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) {
viewModel.uiState.collect { value = it }
}
}
if (uiState is ChatsUiState.Success) {
val chats: LazyPagingItems<AmityChannel> =
(uiState as ChatsUiState.Success).data.collectAsLazyPagingItems()
ChatsScreen(
chats = chats,
navigateToUsers = navigateToUsers,
navigateToConversation = navigateToConversation,
modifier = modifier.padding(8.dp)
)
} else if(uiState is ChatsUiState.Error){
(uiState as ChatsUiState.Error).throwable.localizedMessage?.let {
onError(it)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ChatsScreen(
chats: LazyPagingItems<AmityChannel>,
modifier: Modifier = Modifier,
navigateToUsers: () -> Unit,
navigateToConversation: (String) -> Unit,
state: LazyListState = rememberLazyListState(),
) {
if (chats.itemCount == 0 && chats.loadState.refresh is LoadState.NotLoading && chats.loadState.refresh.endOfPaginationReached) {
EmptyChannelList(modifier = modifier, navigateToUsers = navigateToUsers)
}
chats.apply {
when {
loadState.refresh is LoadState.Loading
|| loadState.append is LoadState.Loading
|| loadState.prepend is LoadState.Loading -> {
LoadingChannels()
}
}
}
Column {
TopAppBar(...)
LazyColumn(modifier = modifier, state = state) {
items(
count = chats.itemCount,
key = chats.itemKey { it.getChannelId() },
contentType = chats.itemContentType { it.getChannelType() }
) { index ->
chats[index]?.let {
ChatsRow(chat = it, navigateToConversation = navigateToConversation)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
}
我们的频道列表
对于对话屏幕,我们还将使用 aLazyColumn来显示之前的消息。
@Composable
fun ConversationScreen(
modifier: Modifier,
onError: (String) -> Unit,
navigateBack: () -> Unit,
channelId: String?,
viewModel: ConversationViewModel = hiltViewModel()
) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
if (channelId == null) {
Log.e("ConversationScreen", "ConversationScreen: channel id was null")
navigateBack.invoke()
}
requireNotNull(channelId)
LaunchedEffect(key1 = lifecycle, key2 = viewModel.uiState) {
viewModel.getConversation(channelId)
}
val uiState by produceState<ConversationUiState>(
initialValue = ConversationUiState.Loading,
key1 = lifecycle,
key2 = viewModel
) {
lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) {
viewModel.uiState.collect { value = it }
}
}
when (uiState) {
is ConversationUiState.Error -> {
(uiState as ConversationUiState.Error).throwable.localizedMessage?.let(onError)
navigateBack.invoke()
}
ConversationUiState.Loading -> { LoadingScreen() }
is ConversationUiState.Success -> {
ConversationScreen(
channel = (uiState as ConversationUiState.Success).data,
modifier = modifier,
navigateBack = navigateBack,
onError = onError
)
}
}
}
@Composable
internal fun ConversationScreen(
channel: AmityChannel,
modifier: Modifier = Modifier,
navigateBack: () -> Unit,
onError: (String) -> Unit,
viewModel: ConversationViewModel = hiltViewModel()
) {
Box(modifier = modifier.fillMaxSize()) {
TopAppBar(...)
Scaffold(modifier = modifier.fillMaxSize(), bottomBar = {
ComposeMessageBox(channelId = channel.getChannelId(), onError = onError)
}) { paddingValues ->
MessageHistory(
modifier = modifier
.fillMaxSize()
.padding(paddingValues),
currentUserId = viewModel.currentUserId,
channelId = channel.getChannelId()
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun ComposeMessageBox(
channelId: String,
onError: (String) -> Unit,
viewModel: ConversationViewModel = hiltViewModel()
) {
var msg by rememberSaveable { mutableStateOf("") }
val keyboardController = LocalSoftwareKeyboardController.current
Row(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
.wrapContentHeight(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
BasicTextField(
value = msg,
onValueChange = { msg = it },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Default
),
modifier = Modifier.weight(1f),
textStyle = TextStyle(color = MaterialTheme.colorScheme.primary),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Row(
modifier = Modifier
.fillMaxWidth()
.border(
width = 2.dp,
color = MaterialTheme.colorScheme.secondary,
shape = RoundedCornerShape(size = 16.dp)
)
.padding(8.dp)
) {
innerTextField()
}
}
)
IconButton(
onClick = {
viewModel.sendMessage(
channelId = channelId,
msg = msg,
onError = {
onError(stringResource(id = R.string.chat_message_error))
})
msg = ""
},
enabled = msg.isNotBlank()
) {
Icon(
imageVector = Icons.Default.Send,
contentDescription = stringResource(id = R.string.chat_send_message)
)
}
}
}
@Composable
internal fun MessageHistory(
modifier: Modifier,
currentUserId: String,
channelId: String,
state: LazyListState = rememberLazyListState(),
viewModel: ConversationViewModel = hiltViewModel()
) {
val scope = rememberCoroutineScope()
val messages = viewModel.getHistory(channelId).collectAsLazyPagingItems()
LazyColumn(modifier = modifier, state = state, horizontalAlignment = Alignment.Start, reverseLayout = true) {
// always scroll to show the latest message
scope.launch {
state.scrollToItem(0)
}
items(
count = messages.itemCount,
key = messages.itemKey { it.getMessageId() },
contentType = messages.itemContentType { it.getDataType() }
) { index ->
messages[index]?.let {
MessageRow(it, it.getCreatorId() == currentUserId)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
我们现在有一个屏幕可以阅读和发送消息🎉
关注公众号:Android老皮
解锁 《Android十大板块文档》 ,让学习更贴近未来实战。已形成PDF版
内容如下:
1.Android车载应用开发系统学习指南(附项目实战)
2.Android Framework学习指南,助力成为系统级开发高手
3.2023最新Android中高级面试题汇总+解析,告别零offer
4.企业级Android音视频开发学习路线+项目实战(附源码)
5.Android Jetpack从入门到精通,构建高质量UI界面
6.Flutter技术解析与实战,跨平台首要之选
7.Kotlin从入门到实战,全方面提升架构基础
8.高级Android插件化与组件化(含实战教程和源码)
9.Android 性能优化实战+360°全方面性能调优
10.Android零基础入门到精通,高手进阶之路
敲代码不易,关注一下吧。ღ( ´・ᴗ・` ) 🤔