JetpackCompose的第一次项目实践 | 经验分享

未命名的设计 (1).png

前言

Jetpack Compose想必各位安卓开发者已经不太陌生了,因为最近JetBrains-Compose for iOS开始Alpha**,对使用Compose进行跨平台的渴望又进了一步。

事实上之前我也接触过Compose,做过点小游戏,但一直没有真正去做一个项目来学习和实践它,所以我想要做一个项目来一边学习一边实践。

但为什么没有选择jb的Compose做跨平台,而是选择了安卓的Jetpack Compose?事实上我认为自己对Compose的理解还比较浅,包括语法什么的,都不怎么熟悉。而且跨平台的Compose目前有许多组件没有实现,而我想要把这个项目的成果分享给大家使用,因此我就不得不选择Jetpack Compose

当然这不是妥协,因为都是Compose,在界面上无非是API和属性上的差异,逻辑基本上可以复用,当然除了一些jetpack的组件比如viewmodel,但是我们随时可以尝试把此项目移植到跨平台上,当然,这需要时间,以后有机会我会和大家分享怎么迁移跨平台。

文末有开源地址

项目来历

这次我做的项目叫食选,因为上学期在学校食堂吃到已经不知道吃什么了,当时因为玩Compose,就做了个只有一个页面的APP,功能就是录入食物名称,然后点击可以抽取一份已经录入的食物,接下来我就知道吃什么了,哈哈,相当于随机抽取一个标签的程序。

上一次只是做了一个简单的界面,功能也比较单调,因此这次打算做个完善的。只不过这次并不打算只做它一个功能。

第一个,我最近在网上看到了一个程序员在家做饭方法指南隔离使用手册,简单来讲就是做菜的,但是后者有一个小功能,就是你可以选择你已经有的食材和厨具,根据这些选择的烹饪材料来推荐有哪些菜品可以做。当结果出现后,点击结果可以跳转到B站的视频播放页,内容就是这道菜的制作教程。

第二个就是我刚刚提到抽取食物的功能,当然后面也许还会扩充一些功能,但是现在一点一点来吧。

项目计划

我对整个项目的技术选择和框架设计,有一部分参考了Google的这个项目:nowinandroid: A fully functional Android app built entirely with Kotlin and Jetpack Compose

业务架构设计

最近谷歌在推一种由数据驱动UI,且UI和数据层之间只有单向流动关系的分层架构。想必图可能有许多读者已经看过许多次了,但是我这块还是想放出来一下。

image.png

如图,UI只是收集Data和网络层的数据,通过不同的数据来改变UI设计。

为了实现这种设计,这里我们采用MVI的架构设计。

MVI

image.png

这个图可能大家也看过了,我简单来说一下吧。

Model

这个就是我们熟悉的Model了,这里放着的是界面的逻辑,UI通过绑定Model后就可以获取相应的东西。

View

展示的界面就是View了,当然这里还包括各种自定义组件,View绑定Model后就可以获取其中的数据并且展示了。

Intent

界面各种操作的意图,简单来讲,我们点击一个按钮,假设需要进行登录验证,那么这就是一个意图。

State

同意图一样State存在于Model中,View绑定Model实际上是绑定了其中的State,当State改变UI就要发送变化,而UI发送意图,Model的逻辑处理最终也要转换到State上,这样才能驱动UI发生改变。

现在大家看看上面的流程图应该会理解许多。

项目架构设计

前面提到了我学习的是Google的nowinandroid项目,因此此次也是采用了类似nowinandroid的模块化。与之前做过的组件化项目不同,模块化后几乎不能被独立拆解,依赖于多个模块使用。

image.png

简单来讲,整个程序目前被分为了3大层,同时我们考虑使用Gradle的统一依赖管理来对各个模块的库进行管理,这也代表我们这次将使用KTS。

APP层

该层负责将其他的模块引入进来,合并为一个完整的APP。
事实上APP层就是负责导航处理,实现一个主页和主要的页面,其他页面都在对应的模块里编写。

core层

该层主要是一些通用的业务,比如数据持久化,数据模型,网络请求,自定义UI组件,以及公共依赖模块。其他模块需要时就引入这些模块。

feature层

该层主要是一些通用的业务,比如数据持久化,数据模型,网络请求,自定义UI组件,以及公共依赖模块,其他模块需要时就引入这些模块。

选择基础库

事实上我们还需要准备一些事情,比如选择一些常用的库,帮我们加速开发。

程序导航

此次采用了一个activityComposable的组合,这就意味着我不能利用activity的栈来管理界面的载入和退出。

因此我选择了Google的一个导航库使用 Compose 进行导航  |  Jetpack Compose  |  Android Developers

navigation的话它可以帮我们管理界面栈就像是我们之前在avtivity里那样。

依赖注入

我们将许多内容分层,就导致了在各个模块引入时比较麻烦,你可能需要New很多次,这样也造成了耦合度的上升,为此我也选择了一款安卓的依赖注入库。
使用 Hilt 实现依赖项注入  |  Android 开发者  |  Android Developers (google.cn)

剩下的就是我们常见的数据库持久化库Room和网络请求库Retrofit2

OK,现在我们已经准备好了相关的库,接下来让我们完成基础建设。

基建开发

下面的部分建议跟着项目看
1250422131/FoodChoice: 食选,解决生活中每天吃饭,吃什么,做什么,怎么做的问题,此项目也是我对JetpackCompose的MVI架构学习的一次实践。 (github.com)

网络请求封装

我们打开network模块,之前我使用过Ktor的网络请求库,但是因为Retrofit现在对协程也支持了,因此我这次就选择了Retrofit,但是这样还不够,我们需要为Retrofit进行一次封装。

虽然说是封装,但是Retrofit事实上已经封装的很完善了,我们只需要进行一点点的配置。

object RetrofitNiaNetwork {


    val networkApi = Retrofit.Builder()
        .baseUrl("https://api.xxxxx.com/app/cook/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(RetrofitNiaNetworkApi::class.java)
}

上面需要注意的就是addConverterFactory,我们的接口结果基本上都是Json,因此需要配置一个数据解析,这里我们用了Gson。

下面是所有APP接口的一个集合,当然后面要扩展的话,比如切换根路径,那就继续新增一个这样的接口类。

interface RetrofitNiaNetworkApi {


    @GET("cookInfo.php")
    suspend fun getCookFoodData(): CookFoodInfo



    @GET("cookingIngredients.php")
    suspend fun getCookingIngredients(): CookingIngredientsInfo
}

注意这块我们用了suspend关键字,代表需要协程进行挂起。

数据库+依赖注入封装

database模块中,我们需要封装一下Room,使得Room可以被直接注入。

RoomDatabase

以往我们继承RoomDatabase后应该是给它一个工厂模式或者单例,总之让全局保证只有一个RoomDatabase对象,但现在我们有依赖注入就不需要这么做了,我们这里只写获取Dao的方法。

@Database(
    entities = [CookingIngredientEntity::class, CookFoodEntity::class],
    version = 1,
    exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {

    abstract fun cookingIngredientDao(): CookingIngredientDao



    abstract fun cookFoodDao(): CookFoodDao
}

RoomDatabase对象获取

前面我们提到有用Hilt实现依赖注入,现在我们就来实现它,

@Module

@InstallIn(SingletonComponent::class)

object DatabaseModule {
    private const val DB_NAME = "db_food_choice"



    @Provides
    @Singleton
    fun providesFdDatabase(
        @ApplicationContext context: Context,
    ): AppDatabase = Room.databaseBuilder(
        context,
        AppDatabase::class.java,
        DB_NAME,
    ) // 是否允许在主线程进行查询
        .allowMainThreadQueries()
        // 数据库升级异常之后的回滚
        .fallbackToDestructiveMigration().build()
}

这里利用@Module告诉Hilt这是个可以注入的模块,@InstallIn(SingletonComponent::class) 则是指它可以在全局注入。

providesFdDatabase方法则指DatabaseModule被如何创建,其中内部的代码就是创建了一个AppDatabase对象。而AppDatabase被注入时就会调用这个方法来产生AppDatabase,另外这是单例的哦,因为这里采用了 @Singleton

依赖注入Dao

但并不是这样就结束了的,我们更希望我们可以直接拿到Dao对象,还记得我们在AppDatabase中定义的方法吗?正常情况下我们是用一个初始化好的AppDatabase对象调用方法后来拿Dao,但是现在我们用的依赖注入就可以换另一种办法。

@Module

@InstallIn(SingletonComponent::class)

object DaosModule {

    @Provides
    fun providesCookingIngredientDao(
        database: AppDatabase,
    ): CookingIngredientDao = database.cookingIngredientDao()



    @Provides
    fun providesCookFoodDao(
        database: AppDatabase,
    ): CookFoodDao = database.cookFoodDao()
}

就像是刚刚,我们定义了DaosModule 是个Model,而且可以在全局注入,而下面就是分别写了CookFoodDao 和 CookingIngredientDao 被注入时如何产生这个对象。

@Dao
interface CookFoodDao {
    @Query("SELECT * from fc_cook_food where stuff = :stuff ORDER BY id DESC")
    suspend fun selectByStuffList(stuff: String): MutableList<CookFoodEntity>



    @Query("SELECT * from fc_cook_food where name = :name ORDER BY id DESC")
    suspend fun selectByNameList(name: String): CookFoodEntity?

    @Query("SELECT * from fc_cook_food ORDER BY id DESC")
    suspend fun selectList(): MutableList<CookFoodEntity>

    @Insert
    suspend fun inserts(vararg cookFoodEntity: CookFoodEntity)

    @Update
    suspend fun update(cookFoodEntity: CookFoodEntity)
}

最后我们看一个简单的Dao,现在Room也支持协程和KSP,因此大家可以直接选择使用KSP方式导入Room,以及在协程中来执行SQL。

数据源配置

让我们这次进入data模块,这里主要是对网络和本地数据进行整合/更新然后返回的操作,我们需要引入common,database,network,model模块,其中model模块存放的是网络数据的模型。

image.png

下面我拿一个来讲,CookFoodInfoRepository是对CookFoodInfo进行数据整合的类,事实上我们这个项目功能需要实现几个基础的能力。

  • 对Dao层接口进行进一步封装和处理
  • 对网络数据进行获取后同步到本地持久化储存
  • 返回同步成功与失败的结果
class CookFoodInfoRepository @Inject constructor(
    private val cookFoodDao: CookFoodDao,
) {
    suspend fun getCookingFoods(stuff: String) =
        run { cookFoodDao.selectByStuffList(stuff) }

    suspend fun getCookingFoods() =
        run { cookFoodDao.selectList() }



    suspend fun syncWith(): Boolean {
        val cookingFoodInfoResult = runCatching {
            RetrofitNiaNetwork.networkApi.getCookFoodData()
        }
        // 成功的前提下进行
        if (cookingFoodInfoResult.isSuccess) {
            // 较为复杂的但写法清爽语法糖
            cookingFoodInfoResult.getOrNull()?.data?.forEach {
                cookFoodDao.selectByName(it.name)?.apply {
                    cookFoodDao.update(it.asCookFoodEntity().copy(id = id))
                } ?: apply {
                    cookFoodDao.inserts(it.asCookFoodEntity())
                }
            }
        }

        return cookingFoodInfoResult.isSuccess
    }
}

我们使用@Inject对该类的构造方法进行了注解,这代表它可以被注入,其中我们在构造方法里承接了CookFoodDao的对象,回想一下,前面我们使用了依赖注入告知了CookFoodDao可以被注入,因此这里我们就不需要管理了,使用时直接注入即可,无需传参。

上面的代码看起来有一些抽象,事实上getCookingFoods只是承接了下dao层的结果,并没有进行进一步封装,而重点主要是同步这块。

数据同步

syncWith方法是用来同步网络数据到本地的,我们先来看看,首先我们拿到了一个cookingFoodInfoResult变量,向后看变得知这是runCatching的结果,这个是kotlin的内置函数,用来简化trycatch的。而在runCatching中是一段网络请求,怎么样?在协程中网络请求就是这么简单,特别是我们进行了一次封装之后。

在下方我们进行了一次判断检查getCookFoodData是否被请求成功了,那么失败的话就抛异常被,就可能是网络有问题或者服务器处理有问题。

当请求成功时,我们通过getOrNull方法获取一下结果对象,还记得我们前面写的API接口类吗?这里我们就直接拿到返回对象了,剩下的retrofit已经帮我们完成啦。

接下来我们遍历返回结果,selectByName方法则看看有没有查询结果,有的话则就update反之则inserts,这就完成了对数据库内容的同步和更新。

文末

至此,我们已经对项目所需要的基本内容完成了编写,后面就是对功能的UI和逻辑绑定进行编写,这块我们将谈及一些compose的组件,以及路由最终用法。

感谢大家看到这里,后面我还会更新这个项目的其他解释,如果大家发现有错误的地方欢迎指正,随时愿意听取大家的意见。

项目开源

觉得项目不错的话就来个star?
1250422131/FoodChoice: 食选,解决生活中每天吃饭,吃什么,做什么,怎么做的问题,此项目也是我对JetpackCompose的MVI架构学习的一次实践。 (github.com)

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYWTv077' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片