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

前语

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和数据层之间只要单向流动关系的分层架构。想必图或许有许多读者现已看过许屡次了,可是我这块还是想放出来一下。

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

如图,UI仅仅收集Data和网络层的数据,经过不同的数据来改变UI规划。

为了完结这种规划,这儿咱们选用MVI的架构规划。

MVI

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

这个图或许咱们也看过了,我简略来说一下吧。

Model

这个便是咱们了解的Model了,这儿放着的是界面的逻辑,UI经过绑定Model后就能够获取相应的东西。

View

展现的界面便是View了,当然这儿还包括各种自界说组件,View绑定Model后就能够获取其间的数据并且展现了。

Intent

界面各种操作的目的,简略来讲,咱们点击一个按钮,假定需求进行登录验证,那么这便是一个目的。

State

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

现在咱们看看上面的流程图应该会了解许多。

项目架构规划

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

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

简略来讲,整个程序目前被分为了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模块寄存的是网络数据的模型。

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

下面我拿一个来讲,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)