玩Android Compose版本 项目分析

玩Android(compose版本)项目地址:https://github.com/yellowhai/PlayAndroid

1.项目settings.gradle

dependencyResolutionManagement {
    /**
     * 原文中说默认情况下,项目中的存储库会覆盖设置中的存储库 ,可以通过设置模式来更改这种行为
     设置存储库的方法:
     repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
     存储库模式:
     PREFER_PROJECT(true)--首选项目存储库
     PREFER_SETTINGS(false)--首选设置存储库
     FAIL_ON_PROJECT_REPOS(false)--强制设置存储库
     */
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven { url "https://jitpack.io" }
        maven {
            url 'https://maven.aliyun.com/repository/public/'
        }
        maven { url 'https://maven.aliyun.com/repository/public' }
        maven { url 'https://maven.aliyun.com/repository/central' }
        maven { url 'https://maven.aliyun.com/repository/google' }
        maven { url 'https://maven.aliyun.com/repository/public' }
        maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
    }
}
rootProject.name = "PlayAndroid"
include ':app'
include ':common'
include ':h_mine'
include ':toolkit'

还是很容易理解的,repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)这个是新增的,简单看下就行。
引入了app模块(主模块),common模块(通用模块,用于通用组件相关),h_mine模块(我的模块,可以单独编译)。

2.config.gradle

这个文件相信大家都有用过的,配置远程依赖的一个文件,统一管理。

ext{
    isRelease = true

    //android构建配置
    android_models = [
            compileSdk : 32,
            minSdk : 23,
            targetSdk : 32,
            versionCode : 1,
            versionName : "1.0"
    ]
    
    app_id = [
            mine      : 'com.hh.mine',
    ]

    core_ktx_version = '1.7.0'
    appcompat_version = '1.4.0'
    lifecycle_version = '2.4.0'
    material_version = '1.4.0'
    work_version = '2.7.1'
    gson_version = '2.8.9'
    litepal_version = '3.2.3'
    landscapist_version = '1.4.5'
    retrofit_version = '2.9.0'
    okhttp_version = '4.9.3'
    startup_version = '1.1.0'
    XXPermissions_version = '13.2'
    datastore_version = '1.0.0'
    materialDialog_version = '0.6.2'
    accompanist_version = '0.25.0'


    jetpack_compose = [
            material : "androidx.compose.material:material:$compose_version",
            activity : "androidx.activity:activity-compose:$appcompat_version",
    ]

    commonApi = [
            ktx_core        : "androidx.core:core-ktx:$core_ktx_version",
            lifecycle       : "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version",
            appcompat       : "androidx.appcompat:appcompat:$appcompat_version",
            material        : "com.google.android.material:material:$material_version",
            //数据存储
            datastore       : "androidx.datastore:datastore-preferences:$datastore_version",
            //权限处理
            XXPermissions   : "com.github.getActivity:XXPermissions:$XXPermissions_version",
    ]

    net = [
            retrofit    : "com.squareup.retrofit2:retrofit:$retrofit_version",
            converter   : "com.squareup.retrofit2:converter-gson:$retrofit_version",
            okhttp      : "com.squareup.okhttp3:logging-interceptor:$okhttp_version",
            gson        : "com.google.code.gson:gson:$gson_version"
    ]

    accompanist_ui = [
            insets_ui    : "com.google.accompanist:accompanist-insets-ui:$accompanist_version",
            navigation   : "com.google.accompanist:accompanist-navigation-animation:$accompanist_version",
            pager        : "com.google.accompanist:accompanist-pager:$accompanist_version",
            swiperefresh : "com.google.accompanist:accompanist-swiperefresh:$accompanist_version",
            flowlayout   : "com.google.accompanist:accompanist-flowlayout:$accompanist_version",
            systemUi     : "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version",
            webview      : "com.google.accompanist:accompanist-webview:$accompanist_version"
    ]

    database =[
            litepal :  "org.litepal.guolindev:core:$litepal_version"
    ]

    skydoves_coil = [
            landscapist_coil :  "com.github.skydoves:landscapist-coil:$landscapist_version"
    ]

    workManager = [
            // Kotlin + coroutines
            work = "androidx.work:work-runtime-ktx:$work_version"
    ]

    material_dialog = [
            color : "io.github.vanpra.compose-material-dialogs:color:$materialDialog_version",
            core  : "io.github.vanpra.compose-material-dialogs:core:$materialDialog_version",
            datetime  : "io.github.vanpra.compose-material-dialogs:datetime:$materialDialog_version"
    ]

    paging = "androidx.paging:paging-compose:1.0.0-alpha14"

    jetpackComposeLibs = jetpack_compose.values()
    commonApiLibs = commonApi.values()
    netLibs = net.values()
    materialDialogLibs = material_dialog.values()
}

层次还是比较清晰,对于同一组单独建立数组存储,模块中只需要引入一个就将这组全部引入进去了。

3.app模块下的build.gradle

显示引入com.android.application+kotlin-android的插件。
下面配置android闭包,很简单。

其它关于compose的也需要配置下:

 compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion compose_version
        kotlinCompilerVersion kotlin_version
    }

然后是远程依赖:

dependencies {
    // 如果是release模式,就把我的模块加进来
    if(isRelease){
        implementation project(':h_mine')
    }

    // 初始化组件
    implementation "androidx.startup:startup-runtime:$startup_version"
    implementation project(':common')

    // 测试相关
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
    debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"

}

isRelease定义在 config.gradle的顶部。如果是集成编译就为true,单独编译为false。

4.app模块的AndroidManifest.xml

  • 配置Application
  • 配置主页
  • 配置provider,用于初始化sdk
      <!--  用provider初始化sdk-->
        <provider
            android:name="androidx.startup.InitializationProvider"
            android:authorities="${applicationId}.androidx-startup"
            android:exported="false"
            tools:node="merge">
            <meta-data
                android:name="com.hh.playandroid.base.BaseInitializer"
                android:value="androidx.startup" />
        </provider>
    

5.自定义Application

class HhfApp : YshhApplication()

依赖common模块:

open class YshhApplication : Application() {

   lateinit var okbuilder: OkHttpClient

   companion object {
       /**
        * application context.
        */
       @SuppressLint("StaticFieldLeak")
       lateinit var context: Context

       @SuppressLint("StaticFieldLeak")
       lateinit var instance: YshhApplication

       /**
        * application级别的协程
        * 有时我们需要在协程上下文中定义多个元素,组合协程上下文中的元素,使用 + 操作符来创建 CoroutineScope .
        */
       val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
   }

这里懒加载OkHttpClient
定义了全局的协程,静态变量。

  override fun onCreate() {
       super.onCreate()
       context = applicationContext
       instance = this
       initRetrofit()
   }

初始化Retrofit:

 /**
    * 初始化Retrofit
    */
   private fun initRetrofit(token : String = ""): OkHttpClient {
       //请求头
       val headerInterceptor = Interceptor { chain: Interceptor.Chain ->
           val orignaRequest = chain.request()
           val request = orignaRequest.newBuilder()
               .header("Authorization", "Bearer $token")
               .method(orignaRequest.method, orignaRequest.body)
               .build()
           chain.proceed(request)
       }
       val logInterceptor = LogInterceptor {
//            it.logE()
       }
       // 日志类别
       logInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
       // 缓存相关
       val cacheFile =
           File(getExternalFilesDir(Environment.DIRECTORY_PICTURES).toString() + "http_cache")
       val cache = Cache(cacheFile, 104857600L) // 指定缓存大小100Mb
       val builder = OkHttpClient.Builder()
       //        builder.addInterceptor(addQueryParameterInterceptor);
       builder.addInterceptor(headerInterceptor)
       builder.cache(cache)
       builder.addNetworkInterceptor(logInterceptor)
       builder.cookieJar(cookieJar)
       builder.readTimeout(60000, TimeUnit.MILLISECONDS)
       //全局的写入超时时间60s
       builder.writeTimeout(60000, TimeUnit.MILLISECONDS)
       //全局的连接超时时间30s
       builder.connectTimeout(30000, TimeUnit.MILLISECONDS)
       okbuilder = builder.build()
       return builder.build()
   }

   private val cookieJar: PersistentCookieJar by lazy {
       PersistentCookieJar(SetCookieCache(), SharedPrefsCookiePersistor(context))
   }
   
   fun getOkBuilder(): OkHttpClient {
       return okbuilder
   }

6.首页 MainActivity

继承BaseActivity:


/**
* 基类Activity 设置非沉浸式,因为要设置导航栏和状态栏颜色
*/
abstract class BaseActivity : AppCompatActivity() {

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       WindowCompat.setDecorFitsSystemWindows(window, false)
   }

}

onCreate生命周期:

 @Suppress("DEPRECATED_IDENTITY_EQUALS")
    @OptIn(ExperimentalAnimationApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 动画渐变
        overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
        // 设置内容
        setContent {
            Log.e("TEST##", "开始setContent了")
            // 顶层为自定义主题
            HhfTheme(
                // 是否暗色或亮色,跟随系统,如果用户主动设置,跟随用户
                theme = if (isSystemInDarkTheme() || isNight) HhfTheme.Theme.Dark else HhfTheme.Theme.Light,
                // 用户自行设置的颜色主题
                colorTheme = appTheme
            ) {
                // 创建一个MainViewModel
                val viewModel : MainViewModel = viewModel()

                // 如果isSplash为true,就展示SplashView 启动页
                if (viewModel.isSplash) {
                    Log.e("TEST##", "加载闪屏了")
                    SplashView { viewModel.isSplash = false }
                } else {
                    Log.e("TEST##", "加载首页了")
                    // 全局静态变量存 底部控制器
                    CpNavigation.navHostController = rememberAnimatedNavController()
                    // 主页面
                    HhfNavigation()
                    // 进度条
                    DialogProgress()
                }
                // 是否纪念日,将App置灰
                if(isMourningDay()){
                    Canvas(modifier = Modifier.fillMaxSize()){
                        drawRect(color = Color.White,blendMode = BlendMode.Saturation)
                    }
                }
            }
        }
        addCallback()
    }

因为这个app就一个Activity,setContent中的内容就是展现给用户可以看到的所有东西了。
顶层是一个自定义主题:

@Composable
fun HhfTheme(
    theme: HhfTheme.Theme = HhfTheme.Theme.Light,
    colorTheme: Color? = null,
    content: @Composable () -> Unit
) {
    val targetColors = if (theme == HhfTheme.Theme.Dark) {
        colorTheme?.let {
            rememberSystemUiController().setNavigationBarColor(colorTheme)
            DarkColorPalette.apply {
                themeColor = it
            }
        }?:DarkColorPalette
    } else {
        colorTheme?.let {
            rememberSystemUiController().setNavigationBarColor(colorTheme)
            LightColorPalette.apply {
                themeColor = it
            }
        }?:LightColorPalette
    }
    val themeColor = animateColorAsState(targetColors.themeColor, TweenSpec(600))
    val textColor = animateColorAsState(targetColors.textColor, TweenSpec(600))
    val background = animateColorAsState(targetColors.background,TweenSpec(600))
    val listItem =  animateColorAsState(targetColors.listItem,TweenSpec(600))
    val bottomBar =  animateColorAsState(targetColors.bottomBar,TweenSpec(600))
    val divider =  animateColorAsState(targetColors.divider,TweenSpec(600))
    val textFieldBackground =  animateColorAsState(targetColors.textFieldBackground,TweenSpec(600))
    val colors = HhfColors(themeColor.value,
        textColor.value,
        background = background.value,
        listItem = listItem.value,
        bottomBar = bottomBar.value,
        divider = divider.value,
        textFieldBackground = textFieldBackground.value,
        )
    CompositionLocalProvider(LocalHhfColors provides colors) {
        MaterialTheme(
            shapes = Shapes,
            typography = typography
        ) {
            content.invoke()
        }
    }
}

这里用到了一个 CompositionLocalProvider,具体用法可以参考 https://juejin.cn/post/7097890697721675813

在Compose函数里,如果要用到函数外提供的值,一般都要通过函数定义好参数,外部调用时传入进去。
这种方法表面看没啥问题,但在一些场景下就不是很方便。比如一个函数A嵌套一个函数B,函数B嵌套函数C,并且这A、B、C的函数都用到这个参数,那对三个函数来说都需要定义这个参数,就不是那么方便。
那用个全局变量不就可以解决需要重复定义参数,确实可以。但全局变量有个副作用就是影响的范围比较大。本身我们定义的参数只会影响到函数内部。这个时候我们就可以使用CompositionLocal:它具有穿透函数功能的局部变量
CompositionLocal适用场景:用来提供上下文数据,不扩大影响范围。

然后最终主题呈现是用了 MaterialTheme这个类展现。

回到首页,内容区:

  • 闪屏页展示逻辑
    通过一个变量: var isSplash by mutableStateOf(true) 实现控制是否显示
  • 加载首页
    先 展示主页面,覆盖一层 进度条
  • 是否纪念日
    app置灰处理
  • 添加二次点击返回退出app逻辑

首页逻辑基本就这么多了。

7.闪屏ui -> SplashView

看下效果先:

可以看到进入app后有个启动页面,中间是logo。
那这个页面有个渐变动画,淡入淡出效果。

具体ui是这样的:

@Composable
fun SplashView(startMain: () -> Unit) {
    /**
     * 辅助文字颜色变化 建立的一个mutableState包装的变量,实际上就是bool值
     */
    var enabled by remember { mutableStateOf(false) }

    /**
     * 背景颜色,从0.3alpha主题色变为纯主题色,2s的时间
     */
    val bgColor: Color by animateColorAsState(
        if (enabled) HhfTheme.colors.themeColor
        else HhfTheme.colors.themeColor.copy(alpha = 0.3f),
        animationSpec = tween(durationMillis = 2000)
    )

    /**
     * 中间文字颜色动画,如果enable为false,0.3alpha的颜色 到纯白色 2s的时间
     */
    val textColor: Color by animateColorAsState(
        if (enabled) Color.White
        else Color.White.copy(alpha = 0.3f),
        animationSpec = tween(durationMillis = 2000)
    )

    // 外层一个Box+中间一个Text
    Box(
        Modifier
            .fillMaxSize()
            .background(bgColor),
        contentAlignment = Alignment.Center
    ) {
        Text(text = "PlayAndroid", color = textColor,style = MaterialTheme.typography.h5)
    }

    // 开启协程 可以看出Compose里面开启协程的方法
    LaunchedEffect(Unit) {
        enabled = true
        delay(2000)
        // 2s后,设置 mutableStateOf 包装的bool变量 为false 会通知首页重新走setContent方法
        startMain.invoke()
    }
}

这里需要理解 animateColorAsState 是官方提供的一个动画,支持颜色变化的一个composable方法。

然后这里开启了2s延迟,怎么开延迟的,调用LaunchedEffect方法里面的delay,可以实现延迟效果。然后执行startMain回调。

这个回调很简单:viewModel.isSplash = false

虽然很简单,但实际上走了很多流程的。

class MainViewModel : ViewModel() {
    var isSplash by mutableStateOf(true)
}

这里使用mutableStateOf包装了一个布尔值。 上面触发这改变时,会刷新composable注解声明的方法体。这里应该就是刷新MainActivity整个内容区了。

8.主页面外部架构

@ExperimentalAnimationApi
@OptIn(ExperimentalPagerApi::class)
@Composable
fun HhfNavigation() {
    AnimatedNavHost(navController = CpNavigation.navHostController,
        /**
         * 首页默认页面
         */
        startDestination = ModelPath.Main.route,
        enterTransition = { fadeIn(animationSpec = tween(700), initialAlpha = 0f) },
        exitTransition = { fadeOut(animationSpec = tween(700), targetAlpha = 0f) }) {

        /**
         * 定义接收到 main 路由消息后->展示MainContent视图
         */
        composable(ModelPath.Main.route) {
            MainContent()
        }

        /**
         * 接收到 setting 路由消息后 -> 设置页面
         */
        composable(ModelPath.Setting.route) {
            CpSetting(Modifier.fillMaxSize())
        }
        ...

这里使用了一个AnimatedNavHost,这个类是官方提供的。
这个类里面定义了所有我们需要跳转的页面和初始页。
然后这个 需要传一个参数,也就是控制器:
CpNavigation.navHostController = rememberAnimatedNavController()
这里也是官方提供的remember包装的一个控制器。

这里的初始页为: startDestination = ModelPath.Main.route,
本质上就是一个string,首页的路由。

这样会默认展示首页,怎么展示首页呢?

composable(ModelPath.Main.route) {
            MainContent()
        }

那么这个MainContent就是我们的首页。

其它composable里面都是首页可能去哪些页面的路由定义。

那这个比如跳转到设置页面,怎么处理呢?

  • 首先在这个AnimatedNavHost里面定义一个composable闭包
 composable(ModelPath.Setting.route) {
            CpSetting(Modifier.fillMaxSize())
        }
  • 然后再需要跳转的地方调用
    navHostController.navigate(“setting”)
    这个navHostController就是我们之前定义的那个控制器,setting就是路由名称。
    这样子就可以跳转到设置页面了。

9.主页内部架构框

首先看下主页效果图

轮播图+中间列表+底部bar
轮播图+中间列表可以看成一个整体,那就是内容区+底部bar

架构怎么搭建呢?

   Scaffold(bottomBar = {
        AnimatedVisibility(
            visible = bottomSwitch,
            enter = expandVertically() + fadeIn(), // 操作符重载了,第一个为空,就用第二个
            exit = shrinkVertically() + fadeOut()  // 操作符重载了,第一个为空,就用第二个
        ) {
            // 实际的底部bar 高度使用 固有特性测量 可以参考:https://juejin.cn/post/7068164264363556872
            MainBottomBar(
                Modifier
                    .fillMaxWidth()
                    .height(IntrinsicSize.Max),pagerState = pagerState) {
                pagerState.reenableScrolling(coroutineScope, it)
            }
        }
    }) {
        内容区...

最外层一个Scafffold脚手架包裹,有点像Flutter了啊。
然后是一个AnimatedVisibility,这个控制显示隐藏的动画组件。重点看里面的MainBottomBar,就找到了我们的底部bar了。

这个看起来像自定义的,具体怎么实现的呢?

/**
 * App底部bar
 */
@OptIn(ExperimentalPagerApi::class)
@Composable
private fun MainBottomBar(
    modifier: Modifier = Modifier,
    pagerState: PagerState,
    currentChanged: (Int) -> Unit
) {
    /**
     * 这个BottomAppBar是官方的
     */
    BottomAppBar(
        modifier.navigationBarsPadding(),
        cutoutShape = CircleShape,
        backgroundColor = HhfTheme.colors.bottomBar,
        elevation = 5.dp
    ) {
        // 遍历4个tab
        bottomList.forEachIndexed { index, item ->
            // item单独设置 官方组件
            BottomNavigationItem(
                selected = pagerState.currentPage == index, onClick = {
                    /**
                     * item点击事件回调给上层
                     */
                    currentChanged(index)
                }, icon = {
                    /**
                     * icon为官方组件
                     */
                    Icon(
                        item.dashboardState.icon,
                        contentDescription = item.name,
                        tint = if (pagerState.currentPage == index) {
                            HhfTheme.colors.themeColor
                        } else {
                            LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
                        }, modifier = Modifier.size(24.dp)
                    )
                }, label = {
                    Text(
                        text = item.name,
                        color = if (pagerState.currentPage == index) {
                            HhfTheme.colors.themeColor
                        } else {
                            LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
                        },
                        fontSize = 12.sp
                    )
                },
                unselectedContentColor = LocalContentColor.current.copy(alpha = LocalContentAlpha.current),
                selectedContentColor = HhfTheme.colors.themeColor,
                alwaysShowLabel = true

            )
        }
    }
}

这里看出底部bar就是用了官方的BottomAppBar。
里面具体内容是我们开发者自行设定的,这里遍历了bottomList,这个集合就是我们自己定义的一个集合:

/**
 * 底部List集合 这里定义了4块
 * 首页,项目,公众号,我的
 */
val bottomList = listOf(
    BottomBean(
        DashboardState.Home,
        YshhApplication.context.stringResource(R.string.main_title_home)
    ),
    BottomBean(
        DashboardState.Project,
        YshhApplication.context.stringResource(R.string.main_title_project)
    ),
    BottomBean(
        DashboardState.PubAccount,
        YshhApplication.context.stringResource(R.string.main_title_account)
    ),
    BottomBean(
        DashboardState.Mine,
        YshhApplication.context.stringResource(R.string.main_title_mine)
    )
)

就这几个模块。

底部bar的item就是 BottomNavigationitem里面的了,可以看到是一个icon+label构成。有个onClick的参数就是点击事件回调了,这里调用了 回调函数,传递给上层了,传出去的参数就一个int,表示第几页。

回调到哪里呢?

 // 实际的底部bar 高度使用 固有特性测量 可以参考:https://juejin.cn/post/7068164264363556872
            MainBottomBar(
                Modifier
                    .fillMaxWidth()
                    .height(IntrinsicSize.Max),pagerState = pagerState) {
                pagerState.reenableScrolling(coroutineScope, it)
            }

这里通过调用rememberPagerState这个对象的 pagerState.reenableScrolling(coroutineScope, it)这个方法实现滚动到目标tab下。也就实现了页面切换。
最为关键的就是这个pagerState,同步页面数据和底部bar。

等下分析内容区也会用到。

 // 水平分页
            HorizontalPager(
                count = bottomList.size,
                modifier.weight(1f), pagerState, userScrollEnabled = true
            ) { page ->
                when (page) {
                    // 首页tab
                    0 -> HomeView(
                        Modifier
                            .fillMaxSize()
                            .background(HhfTheme.colors.background)
                            .align(Alignment.CenterHorizontally)
                    )
                    // 项目tab
                    1 -> ProjectView(
                        Modifier
                            .fillMaxSize()
                            .align(Alignment.CenterHorizontally)
                    )
                    // 公众号tab
                    2 -> AccountView(
                        Modifier
                            .fillMaxSize()
                            .background(HhfTheme.colors.background)
                            .align(Alignment.CenterHorizontally)
                    )
                    // 我的tab
                    3 -> {
//                        if (isLogin) {
                            Mine(
                                Modifier
                                    .fillMaxSize()
                                    .background(HhfTheme.colors.background)
                                    .align(Alignment.CenterHorizontally)
                            )
//                        } else {
//                            ErrorBox(
//                                Modifier.fillMaxSize(),
//                                title = stringResource(id = R.string.sign_in)
//                            )
//                            { CpNavigation.to(ModelPath.Login) }
//                        }
                    }
                }
            }

这里是中间内容区。通过使用水平分页得以实现。可以看到传入了一个参数,也就是上面底部bar用到的一个pagerState,定义了不同索引下,中间内容区展示的内容。

  • 首页Tab 对应 HomeView
  • 项目Tab 对应 ProjectView
  • 公众号Tab 对应 AccountView
  • 我的Tab 对应 Mine

10.首页Tab-HomeView

首页效果图就是上面的那个效果。
这里再拆分下,就是轮播图+列表。
对应 HomeView。

@CompoKotlinsable
fun HomeView(modifier: Modifier = Modifier) {
    val viewModel: HomeViewModel = viewModel()
    ColumnTopBarMain(modifier
        .background(HhfTheme.colors.background),
        stringResource(R.string.app_name)) {
        // 内容区域,传入HomeViewModel对象
        HomeContent(viewModel = viewModel)
    }
}

最外层是由顶部bar+内容区构成。

ColumnTabBarMain是自定义通用的一个App的组件,用于上面展示toolbar,中间区域自定义。

@Composable
fun ColumnTopBarMain(
    modifier: Modifier = Modifier,
    title: String,
    content: @Composable ColumnScope.() -> Unit
) {
    Column(
        modifier.navigationBarsPadding(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        CpTopBar(
            Modifier.fillMaxWidth(),
            HhfTheme.colors.themeColor,
            title = title,
            actions = {
                IconButton(onClick = {
                    CpNavigation.to(ModelPath.Search)
                }) {
                    Icon(Icons.Filled.Search, contentDescription = "search", tint = Color.White)
                }
            },
        )
        content.invoke(this)
    }
}

这里具体是一个Column+CpTopBar构成

CpTopBar是这样的:


@Composable
fun CpTopBar(
    modifier: Modifier = Modifier,
    backgroundColor: Color = Color.Transparent,
    title: String,
    actions: @Composable RowScope.() -> Unit = {},
    back: (() -> Unit)? = null
) {
    HhTopAppBar(
        {
            Text(title, color = Color.White)
        },
        modifier = modifier,
        backgroundColor = backgroundColor,
        contentPadding = WindowInsets.statusBars.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top).asPaddingValues(),
        navigationIcon = back?.run {
            {
                IconButton(
                    onClick = {
                        invoke()
                    }
                ) {
                    Icon(Icons.Filled.ArrowBack, contentDescription = "back", tint = Color.White)
                }
            }
        },
        actions = actions,
        elevation = 2.dp,
    )
}

里面还有自定义的HhTopAppBar


@Composable
fun HhTopAppBar(
    title: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(0.dp),
    navigationIcon: @Composable (() -> Unit)? = null,
    actions: @Composable RowScope.() -> Unit = {},
    backgroundColor: Color = MaterialTheme.colors.primarySurface,
    contentColor: Color = contentColorFor(backgroundColor),
    elevation: Dp = AppBarDefaults.TopAppBarElevation,
) {
    TopAppBarSurface(
        modifier = modifier,
        backgroundColor = backgroundColor,
        contentColor = contentColor,
        elevation = elevation
    ) {
        TopAppBarContent(
            title = title,
            navigationIcon = navigationIcon,
            actions = actions,
            modifier = Modifier.padding(contentPadding)
        )
    }
}

竟然还有自定义层:Surface是官方提供的了

@Composable
fun TopAppBarSurface(
    modifier: Modifier = Modifier,
    backgroundColor: Color = MaterialTheme.colors.primarySurface,
    contentColor: Color = contentColorFor(backgroundColor),
    elevation: Dp = AppBarDefaults.TopAppBarElevation,
    content: @Composable () -> Unit,
) {
    Surface(
        color = backgroundColor,
        contentColor = contentColor,
        elevation = elevation,
        modifier = modifier,
        content = content
    )
}

还有标题栏内容区: 这个TopAppBar是官方提供的了

@Composable
fun TopAppBarContent(
    title: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    navigationIcon: @Composable (() -> Unit)? = null,
    actions: @Composable RowScope.() -> Unit = {},
) {
    TopAppBar(
        title = title,
        navigationIcon = navigationIcon,
        actions = actions,
        backgroundColor = Color.Transparent,
        elevation = 0.dp,
        modifier = modifier
    )
}

标题栏看完了,那就到内容区了。


/**
 * 首页内容区
 */
@Composable
fun HomeContent(modifier: Modifier = Modifier, viewModel: HomeViewModel) {

    /**
     * list 为首页文章 列表数据;系统会对所有列表项进行组合和布局,无论它们是否可见,
     * 因此如果您需要显示大量列表项(或长度未知的列表),则使用 Column 等布局可能会导致性能问题。
     * Compose 提供了一组组件,这些组件只会对在组件视口中可见的列表项进行组合和布局。
     */
    val list = viewModel.viewStates.homeList.collectAsLazyPagingItems()

    /**
     * 下拉刷新组件
     */
    SwipeRefresh(
        /**
         * 状态,是否正在刷新
         */
        state = rememberSwipeRefreshState(viewModel.viewStates.isRefresh),
        /**
         * 下拉刷新回调,需要更新轮播+列表
         */
        onRefresh = {
            viewModel.dispatch(HomeAction.GetBanner)
            list.refresh()
        }) {
        // 内容区 使用LazyItem包裹
        PagingItem(modifier, list = list,
            errorBlock = {},
            successBlock = {},
            errorAndSuccessClick = {
                /**
                 * 错误后点击回调
                 */
                list.refresh()
            }) {
            /**
             * 仅组成和放置 当前可见项 的竖直滚动列表。它允许您放置不同类型的子级内容。
             * 例如,您可以使用LazyListScope.item添加单个项目,然后使用LazyListScope.items或者LazyListScope.itemsIndexed添加项目列表,后者有当前列表项的索引值。
             */
            LazyColumn{
                /**
                 * 单个item
                 */
                item {
                    BannerPager(Modifier.height(160.dp), viewModel = viewModel)
                }
                /**
                 * items集合
                 */
                items(it) { homeBean ->
                    homeBean?.apply {
                        /**
                         * 首页文字item
                         */
                        HomeListItem(homeBean = this) {
                            /**
                             * 处理点击收藏图标 触发viewModel层调用接口
                             */
                            if(CacheUtils.isLogin){
                                viewModel.dispatch(HomeAction.Collect(homeBean.id, it))
                            }
                            else{
                                CpNavigation.to(ModelPath.Login)
                            }
                        }
                    }
                }
            }
        }
    }
}

list 是专门用于懒加载列表,这个是存放可视区的列表。

数据来源是ViewModel层的viewStates实例。

/**
 * 首页状态,包装首页需要的数据
 */
data class HomeState(
    /**
     * 是否正在刷新
     */
    val isRefresh : Boolean = false,

    /**
     * 轮播列表,数据源
     */
    val bannerList: List<BannerResponse> = listOf(),

    /**
     * 首页数据
     */
    val homeList: Flow<PagingData<ArticleBean>> = Pager(
        PagingConfig(PAGE_SIZE),
        pagingSourceFactory = { HomeSource() }).flow,
)

这个是存放到ViewModel层的首页动态数据。

 // 首页数据监听 通过 mutableStateOf包装 HomeState
    var viewStates by mutableStateOf(
        HomeState(
            homeList = Pager(
                PagingConfig(PAGE_SIZE),
                pagingSourceFactory = { HomeSource() }).flow.cachedIn(viewModelScope)))
        private set

这里用一个mutableStateof包装下。

下面继续回到首页内容区:
然后是一个SwipeRefresh组件,官方提供的。

    SwipeRefresh(
        /**
         * 状态,是否正在刷新
         */
        state = rememberSwipeRefreshState(viewModel.viewStates.isRefresh),
        /**
         * 下拉刷新回调,需要更新轮播+列表
         */
        onRefresh = {
            viewModel.dispatch(HomeAction.GetBanner)
            list.refresh()
        }) {

定义好下拉刷新触发事件。
注意这个list,实际上走的是LazyPagingItems里面的一个refresh方法。
我们在HomeState中定义的homeList是一个Flow包装列表集合。通过调用Flow中扩展的一个collectAsLazyPagingItems函数,就变成LazyPagingItems对象了。

这个list我们放在PagingItem里面。

然后首页内容区里面用了一个 LazyColumn,应该也是配合懒加载和复用官方实现的一个类。
然后里面第一个item就是我们的轮播图了。

/**
 * 顶部轮播图
 */
@OptIn(ExperimentalPagerApi::class)
@Composable
fun BannerPager(modifier: Modifier = Modifier, viewModel: HomeViewModel) {
    val pagerState = rememberPagerState()

    /**
     * 开启协程 内部的闭包是协程 目的是获取轮播图数据
     */
    LaunchedEffect(viewModel){
        viewModel.dispatch(HomeAction.GetBanner)
    }

    /**
     * 如需了解来自父项的约束条件并相应地设计布局,您可以使用 BoxWithConstraints。
     * 就是外部传过来的modifier 决定子View 空间
     */
    BoxWithConstraints(modifier) {

        /**
         * 水平分页
         */
        HorizontalPager(
            count = viewModel.viewStates.bannerList.size, // 轮播总数
            state = pagerState // 轮播状态
        ) { page ->
            //  页面索引
            when (page) {
                viewModel.viewStates.bannerList[page].id -> {
                    /**
                     * 轮播图item
                     */
                    BannerItem(
                        Modifier
                            .height(maxHeight)
                            .fillMaxWidth(), viewModel.viewStates.bannerList[page]
                    )
                }
            }
        }

        /**
         * 轮播指示器
         */
        if (viewModel.viewStates.bannerList.isNotEmpty()) {
            BannerIndicator(
                Modifier
                    .align(Alignment.BottomCenter)
                    .fillMaxWidth()
                    .background(Color.Black.copy(0.2f))
                    .padding(8.dp),viewModel, pagerState )
        }


    }
}

这个轮播图里面开了个协程去获取轮播图数据,只会走一次。
然后一个水平分页里面存放所有轮播图,轮播item为BannerItem,这个是自定义的

@Composable
fun BannerItem(modifier: Modifier = Modifier, data: BannerResponse) {
    data.apply {
        Box(modifier.clickable {
            /**
             * 点击图片,触发跳转WebView,通过bundle传递数据
             */
            CpNavigation.toBundle(ModelPath.WebView, Bundle().apply {
                putString(webTitle, data.title)
                putString(webUrl, data.url)
                putBoolean(webIsCollect, false)
                putInt(webCollectId, data.id)
                putInt(webCollectType, 1)
            })
        }) {
            /**
             * 自定义网络图片
             */
            NetworkImage(
                imagePath,
                Modifier.fillMaxWidth(),
                contentScale = ContentScale.FillBounds,
                defaultImg = R.mipmap.ic_default_round
            )
        }
    }
}

轮播完了,就是文章item了。
主要是为了实现这种效果

 /**
   * items集合
   */
items(it) { homeBean ->
    homeBean?.apply {
        /**
        * 首页文字item
        */
        HomeListItem(homeBean = this) {
            /**
            * 处理点击收藏图标 触发viewModel层调用接口
            */
            if(CacheUtils.isLogin){
                viewModel.dispatch(HomeAction.Collect(homeBean.id, it))
            }
            else{
                CpNavigation.to(ModelPath.Login)
            }
        }
    }
}

这里用了items,然后遍历了 list,每个item对应一个 HomeListItem
这里定义了一个函数,说明了点击收藏图标后的逻辑。

  /**
     * 是否收藏了,用remember包装一下bool值
     */
    var isCollect by remember{ mutableStateOf(homeBean.collect)}

    /**
     * 卡片布局
     */
    Card(
        modifier
            .padding(8.dp) // 内padding 8个dp
            .clickable {
                /**
                 * 卡片点击事件,触发跳转webView
                 */
                CpNavigation.toBundle(ModelPath.WebView, Bundle().apply {
                    putString(webTitle, homeBean.title)
                    putString(webUrl, homeBean.link)
                    putBoolean(webIsCollect, isCollect)
                    putInt(webCollectId, homeBean.id)
                    putInt(webCollectType, 0)
                })
            },
        backgroundColor = HhfTheme.colors.listItem,
        elevation = 5.dp
    ) {

这里外层是用了Card,定义了点击item的逻辑。

里面是这样的:

 Column(Modifier.padding(8.dp)) {
                /**
                 * 第一行 水平布局
                 */
                Row(verticalAlignment = Alignment.CenterVertically) {
                    /**
                     * 分享者没名字
                     */
                    Text(
                        text = if (author.isNotEmpty()) author else shareUser,
                        fontSize = 13.sp,
                        color = HhfTheme.colors.textColor
                    )
                    /**
                     * 显示标签
                     */
                    if(isShowLabel){
                        if (type == 1) {
                            Box(
                                Modifier
                                    .padding(start = 6.dp)
                                    .border(1.dp, Color.Red, RoundedCornerShape(5.dp))
                                    .padding(4.dp)
                            ) {
                                Text(stringResource(id = R.string.istop), Modifier, Color.Red, 10.sp)
                            }
                        }
                        if (fresh) {
                            Box(
                                Modifier
                                    .padding(start = 6.dp)
                                    .border(1.dp, Color.Red, RoundedCornerShape(5.dp))
                                    .padding(4.dp)
                            ) {
                                Text(stringResource(id = R.string.neww), Modifier, Color.Red, 10.sp)
                            }
                        }
                        if (tags.isNotEmpty()) {
                            Box(
                                Modifier
                                    .padding(start = 6.dp)
                                    .border(1.dp, Color(0xFF66BB6A), RoundedCornerShape(5.dp))
                                    .padding(4.dp)
                            ) {
                                Text(tags[0].name, Modifier, Color(0xFF66BB6A), 10.sp)
                            }
                        }
                    }
                    /**
                     * 发布日期
                     */
                    Text(
                        niceDate,
                        Modifier
                            .weight(1f)
                            .wrapContentWidth(
                                Alignment.End
                            )
                            .align(Alignment.CenterVertically),
                        HhfTheme.colors.textColor.copy(0.6f),
                        fontSize = 13.sp,
                    )
                }
                /**
                 * 有没有封面图
                 */
                if (TextUtils.isEmpty(envelopePic)) {
                    // 没有封面图
                    Text(
                        title.filterHtml(),
                        Modifier.padding(top = 12.dp),
                        HhfTheme.colors.textColor,
                        fontSize = 14.sp,
                        fontWeight = FontWeight.Bold
                    )
                } else {
                    // 有封面图时,中间区域展示
                    Row(Modifier.padding(top = 12.dp)) {
                        // 网络图片
                        NetworkImage(
                            envelopePic,
                            Modifier.size(100.dp),
                            contentScale = ContentScale.Crop,
                            defaultImg = R.mipmap.ic_default_round
                        )
                        // 垂直布局
                        Column(Modifier.padding(start = 8.dp)) {
                            // 标题
                            Text(
                                title.filterHtml(),
                                color = HhfTheme.colors.textColor,
                                fontSize = 14.sp,
                                maxLines = 3,
                                style = TextStyle(fontWeight = FontWeight.Bold)
                            )
                            // 摘要描述
                            Text(
                                desc,
                                color = HhfTheme.colors.textInactiveColor,
                                fontSize = 13.sp,
                                maxLines = 3,
                                overflow = TextOverflow.Ellipsis
                            )
                        }
                    }
                }
                // 底部水平布局
                Row {
                    // 文本
                    Text(
                        "$superChapterName / $chapterName",
                        Modifier.padding(top = 12.dp),
                        HhfTheme.colors.textColor,
                        fontSize = 13.sp
                    )
                    // 图标,是否收藏了
                    Icon(
                        if(isCollect) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, contentDescription = "",
                        Modifier
                            .wrapContentWidth(
                                Alignment.End
                            )
                            .weight(1f)
                            .clickable {
                                /**
                                 * 点击收藏,触发请求
                                 */
                                favoriteAction(isCollect)
                                isCollect = !isCollect
                            },
                        tint = HhfTheme.colors.themeColor
                    )
                }
            }

11.我的Tab-Mine

因为项目和公众号和首页基本一样,这里就不重复分析了。
看下我的页面的效果图:

要实现这样的效果,怎么处理呢?

首先开启一个协程,获取用户sp数据,转成UserInfo对象

   /**
     * 开启协程,只走一次
     */
    LaunchedEffect(CacheUtils.userInfo) {
        if (CacheUtils.userInfo != "") {
            /**
             * 根据sp文件,生产一个UserInfo对象,设置给这个我的页面使用
             */
            mineViewModel.dispatch(
                MineViewEvent.SetUserInfo(
                    Gson().fromJson(
                        CacheUtils.userInfo, // sp文件
                        UserInfo::class.java
                    )
                )
            )
            /**
             * 获取积分和排名
             */
            mineViewModel.dispatch(MineViewEvent.GetIntegral)
        }
    }

这里获取积分和排名是需要走接口的
这里会走到ViewModel层这个函数:

    private fun getIntegral() {
        viewModelScope.launch {
            flow {
                emit(
                    TaskApi.create(ApiService::class.java).getIntegral()
                )
            }.map {
                if (it.errorCode == 0) {
                    it.data
                        ?: throw Exception("data null")
                } else {
                    throw Exception(it.errorMsg)
                }
            }.onEach {
                viewStates = viewStates.copy(integral = it)
            }.catch {
                viewStates = viewStates.copy(integral = null)
            }.collect()
        }
    }

这个retrofit中是这样定义的:

interface ApiService {
    /**
     * 获取当前账户的个人积分
     */
    @GET("lg/coin/userinfo/json")
    suspend fun getIntegral(): ApiResponse<Integral>

这个就是一个suspend挂起函数。

顶层为Column:

    /**
     * 顶层为列表视图
     */
    Column(modifier) {
        /**
         * 顶部👤
         */
        MineTopAvatar(
            Modifier
                .fillMaxWidth()
                .height(320.dp)
                .clip(QureytoImageShapes(160f)),
            mineViewModel.viewStates.userInfo?.run {
                nickname
            } ?: "avatar"
        ) {

头像区域,点击后可以弹pop,但是需要先获取权限,这样获取:

// 图片点击事件 会去申请下权限
            XXPermissions
                .with(context)
                .permission(Permission.CAMERA)
                .permission(Permission.MANAGE_EXTERNAL_STORAGE)
                .request(object : OnPermissionCallback {
                    override fun onGranted(
                        granted: List<String>,
                        all: Boolean
                    ) {
                        if (all) {
                            if(isLogin){
                                mineViewModel.dispatch(MineViewEvent.ChangePopupState(true))
                            }
                            else{
                                CpNavigation.to(ModelPath.Login)
                            }
                        }
                    }

                    override fun onDenied(
                        denied: List<String>,
                        never: Boolean
                    ) {
                        if (never) {
                            context.showToast(context.stringResource(R.string.permissions_cm_error))
                            XXPermissions.startPermissionActivity(
                                context,
                                denied
                            )
                        } else {
                            context.showToast(context.stringResource(R.string.permissions_camera_error))
                        }
                    }
                })

用了一个三方库实现。

中间操作栏这样实现:

  // 操作栏 有个外边框,包裹菜单项,底部有阴影
        Surface(
            Modifier
                .padding(start = 20.dp, end = 20.dp)
                .fillMaxWidth()
                .background(HhfTheme.colors.background),
            elevation = 2.dp,
            shape = RoundedCornerShape(8.dp),
            color = MaterialTheme.colors.surface, // color will be adjusted for elevation
        ) {

具体的菜单项也是用懒加载实现:

LazyColumn(Modifier.background(HhfTheme.colors.listItem)) {
                itemsIndexed(list) { i, bean ->

                    /**
                     * 我的页面遍历菜单项
                     */
                    MineItem(
                        Modifier
                            .fillMaxWidth()
                            .clickable {
                                /**
                                 * 分发开发者行为
                                 */
                                mineViewModel.dispatch(MineViewEvent.ToComposable(bean.id))
                            }
                            .height(45.dp), bean.name, HhfTheme.colors.themeColor,
                        bean.icon)
                    if (i < list.size - 1) {
                        /**
                         * 非最后一行显示分割线
                         */
                        Divider(
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(start = 10.dp, end = 10.dp)
                        )
                    }
                }
            }

具体的菜单item,有图标+文字+右侧箭头实现:


@Composable
fun MineItem(
    modifier: Modifier = Modifier, textName: String, iconColor: Color, icon: ImageVector
) {
    Row(
        modifier,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(
            icon, "$textName icon",
            Modifier
                .size(28.dp)
                .padding(start = 10.dp), iconColor
        )
        Text(
            textName,
            Modifier.padding(start = 10.dp),
            fontSize = 14.sp,
            fontFamily = FontFamily.Serif,
            color = HhfTheme.colors.textColor
        )
        Icon(
            Icons.Filled.KeyboardArrowRight, textName,
            Modifier
                .weight(1f)
                .padding(end = 10.dp)
                .wrapContentWidth(Alignment.End),
            tint = HhfTheme.colors.textColor
        )
    }
}

UI搞定了,跳转逻辑怎么处理呢?
答案是viewModel层实现。

这里有个clickable点击闭包:
mineViewModel.dispatch(MineViewEvent.ToComposable(bean.id))

会委托给mineViewModel处理:

    /**
     * 分发开发者行为
     */
    fun dispatch(action: MineViewEvent) {
        when (action) {
            is MineViewEvent.Blur -> bitmapBlur(action.s)
            /**
             * 用户点击菜单项
             */
            is MineViewEvent.ToComposable -> toComposable(action.type)
            is MineViewEvent.ChangePopupState -> {
                Log.e("TEST##", "这里触发了viewStates中 isShowpopup 变更为:${action.flag}")
                viewStates = viewStates.copy(isShowPopup = action.flag)
            }
            is MineViewEvent.SetUserInfo -> viewStates = viewStates.copy(userInfo = action.userInfo)
            is MineViewEvent.GetIntegral -> getIntegral()
        }
    }

这里分发开发者行为,其实更像用户行为,有可能不是用户触发,所以我这里称之为开发者行为。

点击菜单项继续分发:

private fun toComposable(type: Int) {
        if(isLogin){
            when (type) {
                0 -> CpNavigation.to(ModelPath.Integral)
                1 -> CpNavigation.to(ModelPath.Collect)
                2 -> CpNavigation.to(ModelPath.Share)
                3 -> CpNavigation.to(ModelPath.Todo)
                4 -> CpNavigation.toBundle(ModelPath.WebView, Bundle().apply {
                    putString(webTitle, "PlayAndroid")
                    putString(webUrl, "https://github.com/yellowhai/PlayAndroid")
                    putBoolean(webIsCollect, false)
                    putInt(webCollectId, 998)
                    putInt(webCollectType, 1)
                })
                5 -> CpNavigation.to(ModelPath.Setting)
            }
        }
        else{
            when (type) {
                4 -> CpNavigation.toBundle(ModelPath.WebView, Bundle().apply {
                    putString(webTitle, "PlayAndroid")
                    putString(webUrl, "https://github.com/yellowhai/PlayAndroid")
                    putBoolean(webIsCollect, false)
                    putInt(webCollectId, 998)
                    putInt(webCollectType, 1)
                })
                5 -> CpNavigation.to(ModelPath.Setting)
                else -> CpNavigation.to(ModelPath.Login)
            }
        }
    }

这里定义了跳转逻辑,这里有回到 第8点主页外部架构 中的导航定义了。
实现跳转到其它页面了,这更像是在布局上叠加,不算是跳转吧。

这个项目还有很多细节值得一探究竟,不过对于初始compose,也差不多了,了解到这个程度也算入门了吧。


   转载规则


《玩Android Compose版本 项目分析》 Jason 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录