构建 Jetpack Compose 聊天应用程序

467 阅读4分钟

目录

  1. 初始化和依赖
  2. 存储库
  3. 视图模型
  4. 导航
  5. 屏幕

初始化和依赖

除了所有 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零基础入门到精通,高手进阶之路

敲代码不易,关注一下吧。ღ( ´・ᴗ・` ) 🤔