WanAndroid

Project Url: bytebitx/WanAndroid
Introduction: 🔥 🔥 🔥 一个充满设计感的 WanAndroid APP,采用 Kotlin 语言,组件化,MVVM+JetPack 架构设计,Arouter、LiveData、ViewModel、Hilt、Room、Retrofit2、协程 Coroutines、Flow 等流行技术。
More: Author   ReportBugs   
Tags:

[toc]

🔥 🔥 🔥 一个充满设计感的 WanAndroid APP,采用 Kotlin 语言,组件化开发,MVVM+JetPack 架构设计,ArouterLiveDataViewModelRoomRetrofit2协程 CoroutinesFlow等流行技术。

API

玩 Android 开放 API

项目截图

项目说明

  • 由于项目中使用了 Hilt 和 Arouter,有大量的注解,因此当 build 项目失败之后,请 clean 之后再 build。

Login-Plugin、apt_annotation、ap_compiler 文件夹涉及到使用APT+ASM+自定义 Gradle 插件技术,和项目其他功能相关性不大,如果对这几个技术不感兴趣,可暂时不管这几个文件夹的 code。

  • 通用依赖库在 common 文件夹下,子组件都在 modules 文件夹下面。
  • 控制子组件单独运行的开关在根目录下的config.gradle文件里面。
  • 整个项目结构清晰简单,将每个 tab 做成一个 module,让你快速上手组件化知识。
  • 那如何将一个 tab 当成一个 module 的呢?具体是怎么实现的呢?具体代码可以查看 MainActivity 里面的写法。
  • 该项目主要是学习如何将项目拆分 module,是为了拆分 module 而拆分,实际项目中需要根据业务去拆分 module。
  • login 模块和 content 模块由于改动较小,所以将这两个模块已上传到 maven 上面;APP 壳工程既可以源码依赖,也可以 aar 依赖。

子组件独立运行

由于项目中使用到有Hilt注解,因此需要在子组件的 Application 添加@HiltAndroidApp 注解;但是当子组件合并到 APP 主工程的时候,由于 RootApplication 也有@HiltAndroidApp 注解,就会导致编译不通过;因此在将子组件合并到 APP 主工程的时候,需要移除子组件的@HiltAndroidApp 注解

网络请求框架使用:

  1. Retrofit2 + 协程 Coroutines + Flow 技术搭建,因此每个模块都有涉及。
  2. 该网络请求框架同时支持多个 BaseUrl 以及动态改变 BaseUrl;如果使用的网络接口的 baseUrl 不是http://www.wanandroid.com 则直接在 Retrofit 注解上写上完整的请求接口地址即可。具体的实现方式是自定义拦截器,将旧的 baseUrl 替换成新的即可, 详情可见:MultiBaseUrlInterceptor

Room:

  1. 使用到 Room 的模块主要是 module-project 模块,涉及到 Room 的增删改查,定义关联表等知识。
  2. 在组件化的情况下,如果某个子组件需要用到数据库,就不要和其他的组件或 app 工程使用同一个 DB;如果这样就会出现耦合,其实如果某个子组件需要用到 DB,那么就要为该组件定义一个 DB;因为从业务上来讲,该业务被划成子组件,就和其他组件关系不大,因此需要一个独立的 DB。

composeUI:

module-compose 模块使用的是 compose 开发的界面,主要用来学习 compose

Hilt:

  1. 组件化使用 Hilt,需要在主工程和子 module 中加入 hilt 相关依赖
  2. ViewModel 中使用@HiltViewModel 注解,则在 Fragment 或者 Activity 中无法只用 Inject 来实例化 ViewModel,具体实例化方法参考@HiltViewModel 注解注释的内容
  3. ViewModel 中使用@HiltViewModel 注解,是使用 HiltViewModelFactory 来创建 ViewModel 实例,提供了灵活性
  4. ViewModel 中使用@ActivityRetainedScoped 注解,则在 Fragment 或者 Activity 中直接用 Inject 来实例化 ViewModel

Flow StateFlow

  1. 使用 Flow 替代 LiveData 可以参考 module-wechat 模块
  2. 对于项目中是否有必要将 LiveData 替换为 Flow,可以参考这篇文章 和这个视频

Log 工具类

基于timber封装了一个工具类 Logs,方便用户开发阶段和 release 阶段收集日志。
查看线上业务逻辑的 log,需要打印 INFO 及以上级别的日志,即 Logs.i;异常仍然使用 Logs.e
默认的日志 Tag,可在 Logs 文件中的 TAG 变量中修改;如果需要不同文件有自己的 tag,则可以如下设置:

Logs.e(t, tag)

一些知识点

  1. 使用 Flow,不管是请求网络返回数据还是从 DB 中返回数据的时候,已经处于 main 线程了;网络请求和从 DB 中查询数据操作是在子线程。
  2. 开启混淆的时候,所有的实体 bean 都必须加上@Keep注解,让其不混淆
  3. 组件化混淆的时候,可以将通用的和第三方库的混淆配置规则放在 base 里面,然后每个组件如果有单独的库或不需要混淆的地方,单独配置规则
  4. proguard-rules.pro 文件是给 Library 模块自己使用的混淆规则;
    consumer-rules.pro 文件则是会合并到 app 的混淆规则中,是给包括 app 在内的其他模块调用时使用的混淆规则; 详细说明可见该文档
  5. LiveData 粘性事件的解决方式有两种,一种是 hook LiveData,将 Observer 的 mLastVersion 变量设置成和 LiveData 的 mVersion 变量一致;另一种是使用 SingleLiveData;具体实现方式参见代码里面的 LiveDataBus 和 SingleLiveData;注意如果使用 SingleLiveData 的话,如果多个页面使用同一个 SingleLiveData 对象注册 observer,那么只有第一个页面能收到订阅数据;原因是在定义的原子变量身上。
  6. MVC、MVP、MVVM 真正区别

     MVC:view 直接 model 进行交互
             MVC 缺点:
             1.Activity 管理了过多的数据处理逻辑
             2.业务逻辑无法复用
             3.异步任务会出现很多嵌套回调
             4.业务逻辑很多的情况下,Manager 也会很臃肿
    
     MVP:出现它就是为了取代 Activity 的地位
     缺点:
     1.接口文件过多
     2.如果页面逻辑复杂,Activity 需要实现非常多的接口,并且这些接口和具体页面绑定,无法复用
     3.p 层直接持有 view 层的引用,或者会用到 Context,会很容易导致内存泄漏
     4.有时候 p 层需要明确使用 Activity 的时候,需要做强制转换
     5.多个页面无法复用同一个接口
     6.总的来说,p 层和 view 层是高度绑定的,p 层和定义的那些接口无法高效的复用
    
     MVVM:view 通过 ViewModel 订阅所需数据,ViewModel 向 View 提供数据改变的接口,当 view 改变引起数据改变或者数据源发生改变时,ViewModel 通过订阅告诉 view,view 进行视图更新。
     优点:
     1. ViewModel 只提供数据订阅和数据接口,真正做到了和 UI 分离;
     2. ViewModel 伴随 View 生命周期,不会出现内存泄漏问题
     3. 多个页面有相同数据来源,ViewModel 可以复用相同接口并直接获取数据不会重复请求
     4. 可以实现多个页面数据共享问题,并在 Activity 销毁重建的时候,数据不会丢失
    

Arouter 使用

1. 使用 room 之后,组件化操作的时候,如果子 module 有数据存储需求,由于 AppDatabase 在主 module 中,则处理方式有两种:

1.1 在 service 模块,提供方法的时候,将对应的 bean 转为 string,然后在子 module 中调用 service 提供的方法的时候,将获取到的数据转为 string 即可 1.2 在 service 模块,提供方法的时候,定义相应的 bean 即可 1.3 以上做法其实比较耦合,如果子 module 有数据存储需求,其实应该子 module 应该有一个单独的 db。

2. 每个模块需要有

kapt {
    arguments {
        arg("AROUTER_MODULE_NAME", project.getName())
    }
    generateStubs = true
}

3. 每个模块的路由路径的一级目录不能相同

4. 传递参数的时候,参数名称不能是关键字。如:title

5. 接收参数的时候,使用@Autowired 注解的时候,变量不能被赋值

6. 接收参数的时候,可以不使用@Autowired 注解,使用 intent.extras 详见 ContentActivity

7. 不同 module 的布局文件存在同名的情况下,需要按照 module 的名称命名。

  1. 比如登录模块的 toolbar 模块,命名为:reg_login_toolbar,content 模块的 toolbar 命名为:content_toolbar

8. 对提供的服务使用@Autowired 注解获取实例的时候,不能是 private,否则编译不通过

9. 接上一条,在使用服务的实例的之前,需要调用

ARouter.getInstance().inject(this)

10. 拦截器的使用

在跳转到某个页面的时候,如果目标页面是需要已经登录了才能跳转的页面,那么可以使用 Arouter 的拦截器实现;如果登录了就直接跳转到目标页面,如果没有登录就跳转到登录页面。拦截器实现如下:

/**
 * @Description:
 * 该拦截器的作用是:统一页面跳转时,判断用户是否已经登录,
 * 将业务层判断用户是否登录的逻辑统一到这里,业务层就不需要做 if else 判断了
 * @Author: wangyuebin
 * @Date: 2021/9/17 2:25 下午
 */
@Interceptor(name = "login", priority = 1)
class LoginInterceptor : IInterceptor {

    /**
     * 该集合保存的是需要登录成功之后才跳转的页面,也就是有@RequireLogin 注解的页面
     */
    private lateinit var pageList: List<String>

    private var isLogin: Boolean = false

    override fun init(context: Context?) {
        pageList = RefletionUtils.getRequireLoginPages()
        isLogin = RefletionUtils.getLoginStatus()
    }

    /**
     * 在该项目中,也可以使用 AppUtils.isLogin 来直接判断是否登录,但是这样就耦合了 AppUtils
     * 如果不想耦合,就可以使用 RefletionUtils.getLoginStatus()来判断是否登录,不过
     * 前提是需要为 AppUtils 的 isLogin 变量增加@InjectLogin。这样做的好处就是:
     * 可以将 LoginInterceptor 和 RefletionUtils 单独拿出来作为一个 lib。
     */
    override fun process(postcard: Postcard, callback: InterceptorCallback) {
        // 判断哪些页面需要登录 (在整个应用中,有些页面需要登录,有些是不需要登录的)
        if (pageList.contains(postcard.destination.canonicalName)) {
            if (RefletionUtils.getLoginStatus()) { // 如果已经登录了,则默认不做任何处理
                callback.onContinue(postcard)
            } else { // 未登录,拦截
                callback.onInterrupt(null)
            }
        } else {
            callback.onContinue(postcard)
        }
    }
}

从上面代码中可以看出,只需要统计已经登录了才能跳转的页面,将其存入一个 List 集合中,然后在拦截器里面做判断就好了。

但是使用 Arouter 只能解决跳转页面的时候判断是否已经登录,在项目中,有些功能是不需要跳转页面就要判断是否登录,登录才执行某个操作,没登录就跳转到登录页面,这个时候 Arouter 就不适用了;例如本项目中的收藏功能。

APT+ASM+自定义 Gradle 插件

  • 在进入我的收藏页面的时候,使用了注解@RequireLogin@InjectLogin两个注解判断该页面是否是需要登录才能进的页面以及是否已经登录了;因此涉及到APT技术
  • ASM+自定义 Gradle 插件部分目前只是做了基本的尝试,目的是学习如何自定义 Gradle 插件以及字节码插装。

编译 ijkPlayer,使其支持 rtsp 及 https

疑问?

  1. 如果新增一个 module,或者新增一个功能,需要用到某个常量,然后主 app 也要用到某个该常量,那么该常量应该定义在哪里?base 里面?如果定义在 base 里面,那么就会经常动 base;如果不定义在 base 里面,那么该定义在哪里?

  2. Arouter 的路由应该放在哪里?如果放在 common-base 中,那么就要经常动 base,如果放在某个 module 中,那么其他的 module 就无法使用,除非在该再定义一个相同的路由。

鼓励

通过这个项目希望能够帮助大家更好地学习 Jetpack 与 MVVM 架构。欢迎您提出项目架构设计中设计不合理的地方或者提出更优的解决方案,大家共同进步。如果你喜欢 WanAndroid 的设计,感觉本项目的源代码对你的学习有所帮助,可以点右上角 "Star" 支持一下,谢谢!^_^

主要开源框架

LICENSE

Copyright (C) bbgo, Open source codes for study only.
Do not use for commercial purpose.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apps
About Me
GitHub: Trinea
Facebook: Dev Tools