[toc]
持久层、依赖注入、Room、DataStore、数据流 Flow、Retrofit、okhttp、Navigation、ChatGPT、OpenAI、RestAPI、Kotlin、ViewModel、UI
最开始想写这个应用时是打算采用老师在课堂上演示的青云客的网络接口,但是后来又找到了openAi官方开放的接口,该接口所支持的案例比较丰富,具体可以参考官方案例,在了解了相关接口的调用方式以及对应参数的实际含义后,就开始着手开发.
由于是聊天应用,首先想到的便是类似于微信
和QQ
这样的聊天界面,同时在谷歌的开发者网站中的入门教程中也是将一个聊天页面作为入门级的教程的.其次 OpenAI 的官网中有很多案例,这些案例对应着请求接口时的不同参数,故我们的应用应当提供对应的自定义参数功能,以达到官网中的各种 案例要求.最后需要提供注册登录的功能,也就是说将用户的数据与登录的账号进行绑定.
用户的数据将和注册的账号绑定存储在设备上
graph LR
A(持久层) -->B(Room)
A --> C(DataStore)
持久 层的作用主要就是保存用户的数据,包括用户与机器人对话的记录,用户注册时的信息以及相关联的配置项,当应用退出后再重新进入时,通过获取数据库中的数据,再加载到界面上,以实现数据"持久"存在的目的.
- Config(配置表)
- Message(消息表)
- User(用户表)
是一个数据库类,可为您的应用提供与该数据库关联的 DAO 实例。
在不需要存储关系型数据的情况下,DataStore Jetpack 组件非常适合存储简单的小型数据集,且开销较低。
在本应用中,通过 DataStore
存储应用的主题颜色以及字体大小的配置信息
网络层使用retrofit
对接口进行访问,获取对应的数据,再通过gson
可以将得到的json
数据转化为我们事先定义的对象
例如,我们定义该接口返回的对象如下
data class ChatResponse(
val id: String,
val created: Int,
val model: String,
val choices: List<Choice>,
val usage: Usage
)
主要就是对各个界面的 UI 实现,这其中通过导航组件Navigation
进行联系,实现多界面之间的跳转
主要保存界面中的 临时数据,防止activity
生命周期变动时数据丢失的情况
网络层使用的主要依赖是retrofit
,在内存、速度和性能方面,对 Retrofit 对象调用 create()
函数的成本很高。该应用只需要一个 Retrofit API 服务实例,因此使用对象声明向应用的其余部分公开该服务。
private const val BASE_URL =
"https://api.openai.com/v1/"
var httpBuilder = OkHttpClient.Builder()
// 设置超时,超过 60s 视为超时
var client = httpBuilder.readTimeout(60, TimeUnit.SECONDS)
.connectTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
private val retrofit = Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(BASE_URL)
.client(client)
.build()
interface ChatApiService {
@Headers(
"Content-Type:application/json",
"Authorization:Bearer YOUR_KEY"
)
@POST("completions")
fun getRestaurants(@Body requestData: RequestBody): Call<ChatResponse>
}
object ChatApi {
val retrofitService: ChatApiService by lazy {
retrofit.create(ChatApiService::class.java)
}
}
同时存在的一个问题是由于该接口的请求在某些时候可能耗时比较长,故我们需要将超时时间设置的长一些,否则默认的超时时长在14s
左右,超过 14s 不会继续等待,我们需要将这个时间调整的长一些,60s
可以满足绝大多数的请求
在MainViewModel
中向接口请求数据,得到AI
的回复
fun getAiReply(content: String) {
updateMessageUiState(content, true) // 将用户的输入进行记录
val requestBody = configUiState.toRequestBody(content)
restaurantsCall = ChatApi.retrofitService.getRestaurants(requestBody)
restaurantsCall.enqueue(
object : Callback<ChatResponse> {
override fun onResponse(
call: Call<ChatResponse>,
response: Response<ChatResponse>
) {
response.body()?.choices?.get(0)?.let {
viewModelScope.launch {
val realTimeConfig =
async { configRepository.getConfigByUserId(userId) }.await()
if (realTimeConfig.id != 0) {
// 使用最新的机器人名称
configUiState =
configUiState.copy(robotName = realTimeConfig.robotName)
}
updateMessageUiState(it.text.trim(), false)
}
} }
override fun onFailure(call: Call<ChatResponse>, t: Throwable) {
t.printStackTrace()
Log.e("MYTEST", "获取信息失败")
}
})
}
总体架构如下,主要是一个聊天消息列表和下面的输入框
@Composable
fun ChatScreen(
viewModel: MainViewModel = viewModel(factory = AppViewModelProvider.Factory)
) {
val listState = viewModel.listState
Surface() {
Box(modifier = Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
ChatDisplay(
viewModel,
modifier = Modifier.height(510.dp),
listState = listState
)
UserInput(viewModel, listState)
}
} }}
ChatDisplay
定义如下,由于聊天消息是一个较长的列表,故我们采用的是LazyColumn
组件
@Composable
fun ChatDisplay(
viewModel: MainViewModel,
modifier: Modifier = Modifier,
listState: LazyListState
) {
val chatUiState by viewModel.chatListState.collectAsState()
Surface(modifier = modifier) {
LazyColumn(
modifier = Modifier
.background(MaterialTheme.colors.background),
state = listState
) {
items(chatUiState.chatList) { item ->
MessageItem(messageUiState = item.toMessageUiState())
}
} }}
对于单个消息的展示函数MessageItem
如下
@SuppressLint("SimpleDateFormat")
@Composable
fun MessageItem(messageUiState: MessageUiState) {
Row(
modifier = Modifier
.padding(all = 8.dp)
) {
Image(
painter = painterResource(if (messageUiState.isSelf) R.drawable.user_avatar else R.drawable.robot_avatar),
contentDescription = null,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colors.secondaryVariant, CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Row {
Text(
text = messageUiState.name,
color = MaterialTheme.colors.secondaryVariant,
style = MaterialTheme.typography.subtitle2
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = messageUiState.dateStr,
style = MaterialTheme.typography.subtitle2,
fontWeight = FontWeight.Light,
letterSpacing = 0.25.sp
)
}
Spacer(modifier = Modifier.height(4.dp))
Surface(
shape = MaterialTheme.shapes.medium,
elevation = 3.dp,
color = if (messageUiState.isSelf) MaterialTheme.colors.secondary else MaterialTheme.colors.surface,
modifier = Modifier
.animateContentSize()
.padding(1.dp)
) {
SelectionContainer {
Text(
text = messageUiState.content,
modifier = Modifier.padding(all = 4.dp),
style = MaterialTheme.typography.body1
)
}
} } }
}
- NavController:负责在目标页面(即应用中的屏幕)之间导航。 - NavGraph:用于映射要导航到的可组合项目标页面。 - NavHost:此可组合项充当容器,用于显示 NavGraph 的当前目标页面。
首先我们在MainActivity
中设置主组合函数ChatRobotApp
,在主组合函数中
class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ChatRobotTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
ChatRobotApp()
}
} }
}
}
在ChatRobotApp
中设置一个组合函数ChatNavHost
,这里我们需要传入navController
参数,后续的页面之间的导航都依赖于此参数
@Composable
fun ChatRobotApp(navController: NavHostController = rememberNavController()){
ChatNavHost(navController = navController)
}
我们在ChatNavHost
组合项中对NavHost
进行封装
@Composable
fun ChatNavHost(
navController: NavHostController,
modifier: Modifier = Modifier,
) {
NavHost(
navController = navController,
startDestination = "login"
) {
composable(route = "login") {
LoginScreen(
navController = navController
)
}
composable(
route = "home/{userId}",
arguments = listOf(navArgument("userId") {
type = NavType.IntType
})
) {
MainScreen(
navController = navController
)
}
}
}
才上面的组合项中可以清晰地看出,我们的应用实际上只有两个页面,一个是登录页面login
,一个是主界面home
,这两个界面的主要关系如下
graph LR
A(login)-->|登录成功,传递用户id| B(home)
A-->|登录/注册失败或注册成功| A
B-->|退出登录|A
当用户在登录页面登录成功后,数据库会返回当前登录用户的id,在跳转到主界面home
时会携带此参数,当加载主界面后,对应的ViewModel
会以当前的用户id
,去数据库中获取当前用户的数据,反映到界面上
首先,我们提供三个输入框,注册时由于存在密码保护,为了防止用户输入有误,我们要求用户输入两次密码,具体流程如下
graph TD
A(两次密码是否一致) -->|否|B(要求重输)
A --> |是|C(该账号是否已存在)
C --> |是|D(注册失败)
C --> |否|E(注册成功)
对于注册功能中,需要注意的一点是,用户的密码对安全性要求比较高,因此我们将用户信息存入数据库时需要对密码进行MD5加密处理� ^[ MD5加密概述,原理及实现-https://blog.csdn.net/Oliver_xpl/article/details/90214896] ,MD5
加密算法是一种不可逆的加密算法,就算后续数据库泄露,其他人得到的也只是加密后的用户密码,无法还原为明文.
注册按钮组件定义如下
Button(
modifier = modifier
.fillMaxWidth()
.weight(1f)
.padding(start = 8.dp),
onClick = {
loginViewModel.register(
infoAlert = { title: String, content: String ->
alertTitle = title
alertContent = content
showAlertDialog = !showAlertDialog
}
)
}
) {
Text(
text = "注册",
style = MaterialTheme.typography.body2
)
if (showAlertDialog) {
ChatAlertDialog(
title = alertTitle,
content = alertContent,
onDismiss = { showAlertDialog = !showAlertDialog }
)
}
}
该按钮的点击事件onClick
调用对应的viewModel
中的register
函数,我们向register
函数中传入的是一个lambda
表达式,接受两个参数,分别是title
和content
,这分别代表着后续注册结果展示时的对话框的标题和内容
其中showAlertDialog
代表着对话框的显示与否,其定义如下
var showAlertDialog by remember {
mutableStateOf(false)
}
对应的register
函数定义如下
fun register(
infoAlert: (String, String) -> Unit
) {
if (isLoginValid()) {
if (loginUiState.password == loginUiState.passRepeat) {
viewModelScope.launch {
val isRepeat = async { userRepository.isExistSameAccount(loginUiState.account) }
if (!isRepeat.await()) {
userRepository.insert(
User(
0,
"用户",
loginUiState.account,
encode(loginUiState.password),
"这个人很懒,没有签名"
)
)
infoAlert("注册成功","直接登录即可开始使用")
} else {
infoAlert("注册失败","当前账号已被使用")
}
}
} else {
infoAlert("输入有误","两次输入的密码不一致")
}
} else {
infoAlert("输入有误","没有输入两次密码或没有输入账号")
}
}
登录的逻辑和注册很像,其中一个特别的点便是,出于安全起见,在OfflineUserRepository
中,对于登录时从服务器获取到的用户信息进行了加密处理
override suspend fun getUserByAccount(account: String, password: String): User {
val res = userDao.getUserByAccount(account, password)
// 如果查询不到对应的,则返回默认值
// 将返回的密码进行加密处理
return (res ?: User()).copy(password = "******")
}
即将返回的用户信息中的密码替换为******
,若根据用户输入的账号和密码在数据库中找不到对应的数据项,则res
为null
,此时将使用默认值User()
,数据类User
定义如下
@Entity(tableName = "user")
data class User(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val name: String = "游客", // 昵称
val account: String = "1234", // 账号,也是唯一的(和 id 的区别在于账号可以自己定义)
val password: String = "", // 密码,存入数据库前先进行 MD5加密
val description: String = "这个人很懒,什么都没有" // 个性说明
)
实体代表着数据库中的一张表的内容,通过注解@Entity
注明,后面的tableName
代表该实体在数据库中对应的数据表的名称,若没有特别说明,则默认以实体类的名称作为表名
我们通过@PrimaryKey
注解注定数据表的主键,autoGenerate
表示id
自增(需要设置初始值)
@Entity(tableName = "message")
data class Message(
@PrimaryKey(autoGenerate = true)
val id: Int = 0, // id
val name: String, // 昵称
val time: Long, // 时间戳(秒级别)
val content: String, // 内容
@ColumnInfo(name = "user_id")
val userId: Int, // 对应的用户 Id val isSelf: Boolean // 是否为用户,否则为 AI 回复
)
由于数据库中命名规则和kotlin
中的驼峰命名不太一致,为了兼容,我们可以使用@ColumnInfo
注解指定数据类中某属性在数据表中对应的属性名
剩下的配置表
和用户表与此类似
Dao 全称为Data Access Object,即数据访问对象,作用是通过提供抽象接口将持久性层与应用的其余部分分离。功能在于,让在底层持久性层执行数据库操作所涉及的所有复杂性与应用的其余部分分离。这样就可以独立于使用数据的代码更改数据层。
我们以消息表对应的MessageDao
为例,定义如下
@Dao
interface MessageDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(message: Message)
@Query("SELECT * from message WHERE id = :id")
fun getMessage(id: Int): Flow<Message>
@Query("Select * from message where user_id = :userId order by time asc")
fun getAllMessagesByUserId(userId: Int): Flow<List<Message>>
}
我们用@Dao
注解表明当前的接口是Dao
,然后在其中写上对应的操作方法,对于比较基础常用的数据库操作方法,其只需通过简单的注解,如@Insert
即可完成,若需要更加复杂的操作,则需要我们使用@Query
注解,然后在对应的参数列表中传入原生SQL
语句即可
它使用前面任务中的 Entity
和 DAO
。数据库类定义了实体和 DAO 的列表。
可为应用提供定义的 DAO 实例。反过来,还可以使用 DAO 从数据库中检索数据,作为关联的数据实体对象的实例。
此外,应用还可以使用定义的数据实体更新相应表中的行,或者创建新行供插入。
获取 RoomDatabase
实例的一般过程如下^[https://developer.android.com/codelabs/basic-android-kotlin-compose-persisting-data-room]:
- 创建一个扩展 RoomDatabase
的 public abstract
类。您定义的新抽象类将用作数据库持有者。您定义的类是抽象类,因为 Room
会为您创建实现。
- 为该类添加 @Database
注解。在参数中,为数据库列出实体并设置版本号。
- 定义一个返回 ItemDao
实例的抽象方法或属性,Room
会为您生成实现。
- 整个应用只需要一个 RoomDatabase
实例,因此请将 RoomDatabase
设为单例。
- 使用 Room
的 Room.databaseBuilder
创建 (item_database
) 数据库。不过,仅当该数据库不存在时才应创建。否则,请返回现有数据库。
与前面的定义类似,这里我们需要使用@Database
注解
在
companion
对象内,为数据库声明一个私有的可为 null 变量Instance
,并将其初始化为null
。Instance
变量将在数据库创建后保留对数据库的引用。这有助于保持在任意时间点都只有一个打开的数据库实例,因为这种资源的创建和维护成本极高。 @Volatile
注解的变量的值绝不会缓存,所有读写操作都将在主内存中完成。这些功能有助于确保Instance
的值始终是最新的,并且对所有执行线程都相同。也就是说,一个线程对Instance
所做的更改会立即对所有其他线程可见^[https://developer.android.com/codelabs/basic-android-kotlin-compose-persisting-data-room]。
多个线程可能会同时请求数据库实例,导致产生两个数据库,而不是一个。此问题称为竞态条件。封装代码以在 synchronized
块内获取数据库意味着一次只有一个执行线程可以进入此代码块,从而确保数据库仅初始化一次。
定义如下:
@Database(
entities = [Message::class, User::class, Config::class],
version = 7,
exportSchema = false
)
abstract class ChatDb : RoomDatabase() {
abstract val messageDao: MessageDao
abstract val userDao: UserDao
abstract val configDao: ConfigDao
companion object {
@Volatile
private var Instance: ChatDb? = null
fun getDatabase(context: Context): ChatDb {
return Instance ?: synchronized(this) {
Room.databaseBuilder(context, ChatDb::class.java, "item_database")
.fallbackToDestructiveMigration()
.build()
.also { Instance = it }
} }
}
}
存储库基本是对 Dao
层的再次封装,因为 Dao
获取到的数据是直接从数据库请求的,有时我们需要对这个原生的数据进一步处理
这里处于框架设计的理念,我们是先定义一个接口,在里面定义所要实现的方法,例如接口MessageRepository
interface MessageRepository {
fun getMessagesStreamByUserId(userId: Int): Flow<List<Message>>
suspend fun insertMessage(message: Message)
}
然后在具体的具体的OfflineMessageRepository
实现接口中的方法
class OfflineMessageRepository(private val messageDao: MessageDao) : MessageRepository {
override fun getMessagesStreamByUserId(userId: Int): Flow<List<Message>> =
messageDao.getAllMessagesByUserId(userId)
override suspend fun insertMessage(message: Message) = messageDao.insert(message)
}
SQL 和 Room 是功能强大的工具。但是,在您不需要存储关系型数据的情况下,DataStore 可以提供一个简单的解决方案。DataStore Jetpack 组件非常适合存储简单的小型数据集,且开销较低。DataStore 有两种不同的实现:
Preferences DataStore
和Proto DataStore
^[https://developer.android.com/codelabs/basic-android-kotlin-compose-datastore]。
在本应用中,我们通过 DataStore
存储应用的主题颜色以及对应的字体大小,由于只需要存储简单的键值对,故我们只需要使用Preferences DataStore
同Room
操作类似,我们需要建立一个DataStore
的存储库UserPreferencesRepository
,可以注意到该类接受一个参数dataStore
,这就是我们后面要提到的依赖注入
的方式
class UserPreferencesRepository(
private val dataStore: DataStore<Preferences>
) {
private companion object {
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
val FONT_SIZE = stringPreferencesKey("font_size")
const val TAG = "UserPreferencesRepo"
}
val themeConfig: Flow<Boolean> = dataStore.data
.catch {
if (it is IOException) {
Log.e(TAG, "Error reading preferences.", it)
emit(emptyPreferences())
} else {
throw it
}
}
.map { preferences ->
preferences[IS_DARK_THEME] ?: false
}
val fontConfig: Flow<String> = dataStore.data
.catch {
if (it is IOException) {
Log.e(TAG, "Error reading preferences.", it)
emit(emptyPreferences())
} else {
throw it
}
}
.map { preferences ->
preferences[FONT_SIZE] ?: "小"
}
suspend fun saveUserPreference(value: Boolean) {
dataStore.edit { preferences ->
preferences[IS_DARK_THEME] = value
}
}
suspend fun saveUserFontPreference(value: String) {
dataStore.edit { preferences ->
preferences[FONT_SIZE] = value
}
}
}
类通常需要引用其他类。例如,Car
类可能需要引用 Engine
类。这些必需类称为依赖项,在此示例中,Car
类依赖于拥有 Engine
类的一个实例才能运行。
类可通过以下三种方式获取所需的对象:
- 类构造其所需的依赖项。在以上示例中,
Car
将创建并初始化自己的Engine
实例。 - 从其他地方抓取。某些 Android API(如
Context
getter 和getSystemService()
)的工作原理便是如此。 - 以参数形式提供。应用可以在构造类时提供这些依赖项,或者将这些依赖项传入需要各个依赖项的函数。在以上示例中,
Car
构造函数将接收Engine
作为参数。
第三种方式就是依赖项注入!使用这种方法,您可以获取并提供类的依赖项,而不必让类实例自行获取。
下面是一个示例。在不使用依赖项注入的情况下,要表示 Car
创建自己的 Engine
依赖项,代码如下所示:
class Car {
private val engine = Engine()
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.start()
}
这并非依赖项注入的示例,因为 Car
类构造了自己的 Engine
。这可能会有问题,原因如下:
- Car
和 Engine
密切相关 - Car
的实例使用一种类型的 Engine
,并且无法轻松使用子类或替代实现。如果 Car
要构造自己的 Engine
,您必须创建两种类型的 Car
,而不是直接将同一 Car
重用于 Gas
和 Electric
类型的引擎。
- 对 Engine
的强依赖使得测试更加困难。Car
使用 Engine
的真实实例,因此无法使用测试替身针对不同的测试用例修改 Engine
。
从前面的OfflineMessageRepository
中我们就会发现,该类接受一个messageDao
的参数,同理后面的ViewModel
中访问数据库,我们也需要对应的Repository
实例,在本项目中我们采用的是依赖注入
的方式
我们实现 AppContainer
类,它将实例化数据库并将 DAO
实例传递给 OfflineItemsRepository
类
interface AppContainer {
val messageRepository: MessageRepository
val userRepository: UserRepository
val configRepository: ConfigRepository
}
class AppDataContainer(private val context: Context) : AppContainer {
/**
* Implementation for [ItemsRepository]
*/
override val messageRepository: MessageRepository by lazy {
OfflineMessageRepository(ChatDb.getDatabase(context).messageDao)
}
override val userRepository: UserRepository by lazy {
OfflineUserRepository(ChatDb.getDatabase((context)).userDao)
}
override val configRepository: ConfigRepository by lazy {
OfflineConfigRepository(ChatDb.getDatabase(context).configDao)
}
}
在AppViewModelProvider
将存储库传递给ViewModel
,即我们前面提到的依赖注入
,这样可以实现对象的复用
object AppViewModelProvider {
val Factory = viewModelFactory {
initializer {
MainViewModel(
this.createSavedStateHandle(),
chatApplication().container.messageRepository,
chatApplication().container.userRepository,
chatApplication().container.configRepository
)
}
initializer {
LoginViewModel(
chatApplication().container.userRepository,
chatApplication().userPreferencesRepository,
)
}
initializer {
ConfigViewModel(
this.createSavedStateHandle(),
chatApplication().userPreferencesRepository,
chatApplication().container.configRepository,
chatApplication().container.userRepository
)
}
}}
fun CreationExtras.chatApplication(): ChatApplication =
(this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ChatApplication)
其中ChatApplication
定义如下,其中我们定义了两个变量,一个是container
用于数据库实例的注入,userPreferencesRepository
则是用于DataStore
的注入
private const val USER_PREFERENCE_NAME = "user_preferences"
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = USER_PREFERENCE_NAME
)
class ChatApplication : Application() {
lateinit var userPreferencesRepository: UserPreferencesRepository
lateinit var container: AppContainer
override fun onCreate() {
super.onCreate()
container = AppDataContainer(this)
userPreferencesRepository = UserPreferencesRepository(dataStore)
}
}
需要在AndroidManifest.xml
配置文件对其进行注册,此方法将 ChatApplication
类定义为应用的入口点。此代码用于在启动 MainActivity
之前,初始化 ChatApplication
类中定义的依赖项。
后续在ViewModel
中若需要访问存储库,我们只需要在ViewModel
中传入对应的实例,例如MainViewModel
中
class MainViewModel(
savedStateHandle: SavedStateHandle,
private val messageRepository: MessageRepository,
private val userRepository: UserRepository,
private val configRepository: ConfigRepository
) : ViewModel() {
......
......
}
我们在前面提到的AppViewModelProvider
文件中向ViewModel
中传入对应的实例
在协程中,与仅返回单个值的挂起函数相反,数据流可按顺序发出多个值。例如,您可以使用数据流从数据库接收实时更新。 数据流以协程为基础构建,可提供多个值。从概念上来讲,数据流是可通过异步方式进行计算处理的一组数据序列。所发出值的类型必须相同。例如,
Flow<Int>
是发出整数值的数据流。 数据流与生成一组序列值的Iterator
非常相似,但它使用挂起函数通过异步方式生成和使用值。这就是说,例如,数据流可安全地发出网络请求以生成下一个值,而不会阻塞主线程。
数据流包含三个实体:
- 生产者(producer) 会生成添加到数据流中的数据。得益于协程,数据流还可以异步生成数据。 - (可选)中介(Intermediaries) 可以修改发送到数据流的值,或修正数据流本身。 - 消费者(consumer) 则使用数据流中的值。
在本项目中数据流主要是用在从持久层中获取数据,由于持久层中的数据在应用内配置项发生改变时是会变动的,这种变动我们需要及时的推送到应用界面,使界面上显示的数据发生改变.
在持久性层中使用 Flow
。将返回值类型设为 Flow
后,只要数据库中的数据发生更改,就会收到通知。Room
会为保持更新此 Flow
,也就是说,只需要显式获取一次数据。由于返回值类型为 Flow
,Room 还会在后台线程上运行该查询。无需将其明确设为 suspend
函数并在协程作用域内进行调用。
这里我们以用户的消息列表为例进行演示
在OfflineMessageRepository
中我们对getMessagesStreamByUserId
进行重写,调用messageDao
中的getAllMessagesByUserId
方法
override fun getMessagesStreamByUserId(userId: Int): Flow<List<Message>> =
messageDao.getAllMessagesByUserId(userId)
该方法在dao
中定义如下(根据用户id获取聊天记录 并按时间升序排列)
@Query("Select * from message where user_id = :userId order by time asc")
fun getAllMessagesByUserId(userId: Int): Flow<List<Message>>
我们在ViewModel
中使用该方法返回的数据
val chatListState: StateFlow<ChatUiState> =
messageRepository.getMessagesStreamByUserId(userId)
.filterNotNull()
.map { ChatUiState(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
initialValue = ChatUiState()
)
上面的这个数据定义方式中:filterNotNull
是为了过滤掉空数据,map
则是为了将数据库中获取的List<Message>
类型转化为ChatUiState
类型,stateIn
则是将Flow
转化为StateFlow
,以便界面上数据能够被追踪状态的变化
在界面的组合项函数中,若想访问ViewModel
中定义的数据流,只需如下定义
val chatUiState by viewModel.chatListState.collectAsState()
本应用支持用户手动切换为深色模式以及调整应用内全局的字体大小,并且这种改变是持久的,即用户退出软件后再重新进入时之前的配置依然是生效的。
观察MainActivity
中我们会发现在渲染界面之前是设置了对应的主题的,例如本应用的主题为ChatRobotTheme
class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ChatRobotTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
ChatRobotApp()
}
} }
}
}
在ChatRobotTheme
中,我们从LoginViewModel
中获取用户的配置信息,这些配置信息是通过DataStore
存储在设备上的,并且以数据流Flow
的形式获取,这样当用户在应用内改变配置后,这些配置项就会立即生效
@Composable
fun ChatRobotTheme(
loginViewModel: LoginViewModel = viewModel(factory = AppViewModelProvider.Factory),
content: @Composable () -> Unit
) {
val themeState = loginViewModel.themeState.collectAsState().value
val fontState = loginViewModel.fontState.collectAsState().value
val colors = if (themeState) DarkColorPalette else LightColorPalette
val fontTypography = when (fontState) {
"小" -> TypographySmall
"中" -> TypographyMedium
"大" -> TypographyLarge
else -> TypographySmall
}
MaterialTheme(
colors = colors,
typography = fontTypography,
shapes = Shapes,
content = content
)
}
在Type.kt
文件中,我们定义了三套字体风格,主要是针对字体大小的,根据用户的配置,应用会自动匹配三种字体主题,从而实现自定义字体大小的功能
val TypographySmall = Typography(
h5 = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 20.sp,
letterSpacing = 0.sp
),
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
body2 = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
letterSpacing = 0.25.sp
),
subtitle2 = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
letterSpacing = 0.1.sp
),
)
同理通过获取的themeState
变量来控制主题颜色
- 该官方接口还支持根据用户描述生成图片,但是目前本项目没有集成这个接口;
- 用户输入后,等待机器人回复时没有对应的等待动画,体验不是很友好;
- 当用户聊天内容较多后,翻看聊天记录比较麻烦;
- 暂不支持定义背景图,用户头像等操作;
- 没有记住用户的登录状态,每一次使用前都需要重新登陆;
本项目已开源在gitee平台
发行版应用链接,可以在此处获取最新版
数据库持久层参考项目
DataStore参考项目
各种组件的参考项目
导航组件的使用案例
网络请求相关的参考
- Kickstart-Modern-Android-Development-with-Jetpack-and-Kotlin 书籍配套代码
- basic-android-kotlin-compose-training-mars-photos
官方的compose入门教程,很详细,面面俱到
和compose相关的书籍,也是很详细
官方的入门课程,就是做一个聊天界面,本应用受此启发
MD5 加密算法比较详细的解析
本应用中机器人采用的接口