Android组件化开发规范

进行组件化开发有一段时间了,不久后就要开始一个新项目了,为此整理了目前项目中使用的组件化开发规范,方便在下一个项目上使用。本文的重点是介绍规范和项目架构,仅提供示例代码举例,目前不打算提供示例Demo。如果你还不了解什么是组件化以及如何进行组件化开发的话,建议请先看其他组件化入门文章。

定义

组件是 Android 项目中一个相对独立的功能模块,是一个抽象的概念,moduleAndroid 项目中一个相对独立的代码模块。

在组件化开发的早期,一个组件就只有一个 module,导致很多代码和资源都会下沉到 common 中,导致 common 会变得很臃肿。有的文章说,专门建立一个 module 来存放通用资源,我感觉这样是治标不治本,直到后面看到微信Android模块化架构重构实践这篇文章,里面的"模块的一般组织方式"一节提到一个模块应该有多个工程,然后开始在项目对 module 进行拆分。

一般情况下,一个组件有两个 module,一个轻量级的 module 提供外部组件需要和本组件进行交互的接口方法及一些外部组件需要的资源,另一个重量级的 module 完成组件实际的功能和实现轻量级 module 定义的接口方法。

module 的命名规范请参考module名,在下文中使用 module-api 代表轻量级的 module,使用 module-impl 代表重量级的 module

common组件

common 是一个特殊的组件,不区分轻量级和重量级,它是项目中最底层的组件,基本上所有的其他组件都会依赖 common 组件,common 中放项目中所有弱业务逻辑的代码和解决循环依赖的代码和资源。

一个完整的项目的架构如下:

弱业务逻辑代码

何为弱业务逻辑代码?简单来说,就是有一定的业务逻辑,但是这个业务逻辑对于项目中其他组件来说通用的。

比如在 common 组件集成网络请求库,创建一个 HttpTool 工具类,负责初始化网络请求框架,定义网络请求方法,实现组装通用请求参数以及处理全局通用错误等,对于其他组件直接通过这个工具类进行网络请求就可以了。

比如定义界面基类,处理一些通用业务逻辑,比如接入统计分析框架。

解决循环依赖的代码和资源

何为解决循环依赖的代码和资源?比如说 module-a-api 有一个类 Cmodule-b-api 中有一个类 D,在 module-a-api 中需要使用 D,在 module-b-api 中需要使用 C,这样就会造成 module-a-api 需要依赖 module-b-api,而 module-b-api 也会依赖 module-a-api,这就造成了循环依赖,在 Android Studio 中会编译失败。

解决循环依赖的方案就是将 CD 其中的一个,或者两个都下沉到 common 组件中,因为 module-a-apimodule-b-api 都依赖了 common 组件,至于具体下沉几个,这个根据具体的情况而定,但是原则是下沉到 common 组件的东西越少越好。

上面的举的例子是代码,资源文件同样也可能会有这个问题。

module代码结构

一个组件通常含有一个或多个功能点,比如对于用户组件,它有关于界面、意见反馈、修改账户密码等功能点,在 module 中为每一个功能点创建一个路径,里面放实现该功能的代码,比如 ActivityDialogAdapter 等。除此之外,为了集中管理组件内部资源和统一编码习惯,特地将一部分的通用功能路径固定下来。这些路径包括 apiprovidertool 等。

一般情况下 module 的代码架构如下图:

api

该路径下放 module 内部使用到的所有网络请求路径和方法,一般使用一个类就够了,比如:UserApi

object UserApi {

    /**
     * 获取个人中心数据
     */
    fun getPersonCenterData(): GetRequest {
        return HttpTool.get(ApiVersion.v1_0_0 + "authUser/myCenter")
    }
}

ApiVersion 全局管理目前项目中使用的所有 api 版本,应当定义在 common 组件的 api 路径下:

object ApiVersion {
    const val v1_0_0 = "v1/"
    const val v1_1_0 = "v1_1/"
    const val v1_2_2 = "v1_2_2/"
}

entity

该路径下放 module 内部使用到的所有实体类(网络请求返回的数据类)。

对于所有从服务器获取的字段,全部定义在构造函数中,且实体类应当实现 Parcelable ,并使用 @Parcelize 注解。对于客户端使用而自己定义的字段,基本上定义为普通成员字段,并使用 @IgnoredOnParcel 注解,如果需要在界面间传递客户端定义的字段,可以将该字段定义在构造函数中,但是必须注明是客户端定义的字段。

示例如下:

@Parcelize
class ProductEntity(
    // 产品名称
    var name: String = "",

    // 产品图标
    var icon: String = "",

    // 产品数量(客户端定义字段)
    var count: Int = 0
) : Parcelable {
    // 用户是否选择本产品
    @IgnoredOnParcel
    var isSelected = false
}

其中 nameicon 是从服务器获取的字段,而 countisSelected 是客户端自己定义的字段。

event

该路径下放 module 内部使用的事件相关类。对于使用了 EventBus 及类似框架的项目,放事件类,对于使用了 LiveEventBus 的项目,里面只需要放一个类就好,比如:UserEvent

object UserEvent {

    /**
     * 更新用户信息成功事件
     */
    val updateUserInfoSuccessEvent: LiveEventBus.Event<Unit>
        get() = LiveEventBus.get("user_update_user_info_success")
}

注意:对于使用 LiveEventBus 的项目,事件的命名必须用组件名作为前缀,防止事件名重复。

route

该路径下放 module 内部所使用到的界面路径和跳转方法,一般使用一个类就够了,比如:UserRoute

object UserRoute {
    // 关于界面
    const val ABOUT = "/user/about"
    // 常见问题(H5)
    private const val FAQ = "FAQ/"

    /**
     * 跳转至关于界面
     */
    fun toAbout(): RouteNavigation {
        return RouteNavigation(ABOUT)
    }

    /**
     * 跳转至常见问题(H5)
     */
    fun toFAQ(): RouteNavigation? {
        return RouteUtil.getServiceProvider(IH5Service::class.java)
            ?.toH5Activity(FAQ)
    }
}

注意:对于组件内部会跳转的H5界面链接也应当写在路由类中。

provider

该路径下放对外部 module 提供的服务,一般使用一个类就够了。在 module-api 中是一个接口类,在 module-impl 中是该接口类的实现类。

目前采用 ARouter 作为组件化的框架,为了解耦,对其进行了封装,封装示例代码如下:

typealias Route = com.alibaba.android.arouter.facade.annotation.Route

object RouteUtil {

    fun <T> getServiceProvider(service: Class<out T>): T? {
        return ARouter.getInstance().navigation(service)
    }
}

class RouteNavigation(path: String) {

    private val postcard = ARouter.getInstance().build(path)

    fun param(key: String, value: Int): RouteNavigation {
        postcard.withInt(key, value)
        return this
    }
    ...
}

示例

这里介绍如何在外部 moduleuser-impl 跳转至用户组件中的关于界面。

准备工作

user-impl 中创建路由类,编写关于界面的路由和服务路由及跳转至关于界面方法:

object UserRoute {
    // 关于界面
    const val ABOUT = "/user/about"
    // 用户组件服务
    const val USER_SERVICE = "/user/service"

    /**
     * 跳转至关于界面
     */
    fun toAbout(): RouteNavigation {
        return RouteNavigation(ABOUT)
    }
}

在关于界面使用路由:

@Route(path = UserRoute.ABOUT)
class AboutActivity : MyBaseActivity() {
    ...
}

user-api 中定义跳转界面方法:

interface IUserService : IServiceProvider {

    /**
     * 跳转至关于界面
     */
    fun toAbout(): RouteNavigation
}

user-impl 中实现跳转界面方法:

@Route(path = UserRoute.USER_SERVICE)
class UserServiceImpl : IUserService {

    override fun toAbout(): RouteNavigation {
        return UserRoute.toAbout()
    }
}
界面跳转

user-impl 中可以直接跳转到关于界面:

UserRoute.toAbout().navigation(this)

假设 module-a 需要跳转到关于界面,那么先在 module-a 中配置依赖:

dependencies {
    ...
    implementation project(':user-api')
}

module-a 中使用 provider 跳转到关于界面:

RouteUtil.getServiceProvider(IUserService::class.java)
    ?.toAbout()
    ?.navigation(this)
module依赖关系

此时各个 module 的依赖关系如下:

common:基础库、第三方库
user-api:common
user-impl:common、user-api
module-a:common、user-api
App壳:common、user-api、user-impl、module-a、...

tool

该路径下放 module 内部使用的工具方法,一般一个类就够了,比如:UserTool

object UserTool {

    /**
     * 该用户是否是会员
     * @param gradeId 会员等级id
     */
    fun isMembership(gradeId: Int): Boolean {
        return gradeId > 0
    }
}

cache

该路径下放 module 使用的缓存方法,一般一个类就够了,比如:UserCache

object UserCache {

    // 搜索历史记录列表
    var searchHistoryList: ArrayList<String>
        get() {
            val cacheStr = CacheTool.userCache.getString(SEARCH_HISTORY_LIST)
            return if (cacheStr == null) {
                ArrayList()
            } else {
                JsonUtil.parseArray(cacheStr, String::class.java) ?: ArrayList()
            }
        }
        set(value) {
            CacheTool.userCache.put(SEARCH_HISTORY_LIST, JsonUtil.toJson(value))
        }

    // 搜索历史记录列表
    private const val SEARCH_HISTORY_LIST = "user_search_history_list"
}

注意:

  1. 缓存Key的命名必须用组件名作为前缀,防止缓存Key重复。
  2. CacheTool.userCache 并不是指用户组件的缓存,而是用户的缓存,即当前登录账号的缓存,每个账号会单独存一份数据,相互之间没有干扰。与之对应的是 CacheTool.globalCache,全局缓存,所有的账号会共用一份数据。

两种module的区别

module-api 中放的都是外部组件需要的,或者说外部组件和 module-impl 都需要的,其他的都应当放在 module-impl 中,对于外部组件需要的但是能通过 provider 方式提供的,都应当把具体的实现放在 module-impl 中,module-api 中只是放一个接口方法。

下表列举项目开发中哪些东西能否放 module-api 中:

类型 能否放 module-api 备注
功能界面(Activity、Fragment、Dialog) 不能 通过 provider 方式提供使用
基类界面 部分能 外部 module 需要使用的可以,其他的放 module-impl
adapter 部分能 外部 module 需要使用的可以,其他的放 module-impl
provider 部分能 只能放接口类,实现类放 module-impl
tool 部分能 外部 module 需要使用的可以,其他的放 module-impl
api、route、cache 不能 通过 provider 方式提供使用
entity 部分能 外部 module 需要使用的可以,其他的放 module-impl
event 部分能 对使用 EventBus 及类似框架的项目,外部组件需要的可以,其他还是放 module-impl
对于使用了 LiveEventBus 的项目不能,通过 provider 方式提供使用
资源文件和资源变量 部分能 需要在 xml 文件中使用的可以, 其他的通过 provider 方式提供使用

注意:如果仅在 module-impl 中存在工具类,则该工具类命名为 xxTool。如果 module-apimodule-impl 都存在工具类,则 module-api 中的命名为 xxToolmodule-impl 中的命名为 xxTool2

组件单独调试

在开发过程中,为了查看运行效果,需要运行整个App,比较麻烦,而且可能依赖的其他组件也在开发中,App可能运行不到当前开发的组件。为此可以采用组件单独调试的模式进行开发,减少其他组件的干扰,等开发完成后再切换回 library 的模式。

在组件单独调试模式下,可以增加一些额外的代码来方便开发和调试,比如新增一个入口 Actvity,作为组件单独运行时的第一个界面。

示例

这里介绍在 user-impl 中进行组件单独调试。

在项目根目录下的 gradle.properties 文件中新增变量 isDebugModule,通过该变量控制是否进行组件单独调试:

# 组件单独调试开关,为ture时进行组件单独调试
isDebugModule = false

user-implbuild.gradle 的顶部增加以下代码来控制 user-implApplicatonLibrary 之间进行切换:

if (isDebugModule.toBoolean()) {
    apply plugin: 'com.android.application'
} else {
    apply plugin: 'com.android.library'
}

user-implsrc/main 的目录下创建两个文件夹 releasedebugrelease 中放 library 模式下的 AndroidManifest.xmldebugapplication 模式下的 AndroidManifest.xml、代码和资源,如下图所示:

user-implbuild.gradle 中配置上面的创建的代码和资源路径:

android {
    ...
    sourceSets {
        if (isDebugModule.toBoolean()) {
            main.manifest.srcFile 'src/main/debug/AndroidManifest.xml'
            main.java.srcDirs += 'src/main/debug'
            main.res.srcDirs += 'src/main/debug'
        } else {
            main.manifest.srcFile 'src/main/release/AndroidManifest.xml'
        }
    }
}

注意:完成上述配置后,在 library 模式下,debug 中的代码和资源不会合并到项目中。

最后在 user-implbuild.gradle 中配置 applicationId

android {
    defaultConfig {
        if (isDebugModule.toBoolean()) {
            applicationId "cc.tarylorzhang.demo"
        }
        ...
    }
}

注意:如果碰到65536的问题,在 user-implbuild.gradle 中新增以下配置:

android {
    defaultConfig {
        ...
        if (isDebugModule.toBoolean()) {
            multiDexEnabled true
        }
    }
}

以上工作都完成后,将 isDebugModule 的值改为 true,则可以开始单独调试用户组件。

命名规范

module名

组件名如果是单个单词的,直接使用该单词 + apiimpl 的后缀作为 module 名,如果是多个单词的,多个单词小写使用 - 字符作为连接符,然后在其基础上加 apiimpl 的后缀作为 module 名。

示例

用户组件(User),它的 module 名为 user-apiuser-impl;会员卡组件(MembershipCard),它的 module 名为 membership-card-apimembership-card-impl

包名

在应用的 applicationId 的基础上增加组件名后缀作为组件基础包名。

在代码中的包名 module-apimodule-impl 都直接使用基础包名即可,但是在 Android 中项目 AndroidManifest.xml 文件中的 package 不能重复,否则编译不通过。所以 module-impl 中的 package 使用基础包名,而 module-impl 中的 package 使用基础包名 + api 后缀。

package 重复的时候,会报 Type package.BuildConfig is defined multiple times 的错误。

示例

应用的 applicationIdcc.taylorzhang.demo,对于用户组件(user),组件基础包名为 cc.taylorzhang.demo.user,则实际包名如下表:

代码中的包名 AndroidManifest.xml中的包名
user-api cc.taylorzhang.demo.user cc.taylorzhang.demo.userapi
user-impl cc.taylorzhang.demo.user cc.taylorzhang.demo.user

对于多单词的会员卡组件(MembershipCard),其组件基础包名为 cc.taylorzhang.demo.membershipcard

资源文件和资源变量

所有的资源文件:布局文件、图片等全部要增加组件名作为前缀,所有的资源变量:字符串、颜色等也全部要增加组件名作为前缀,防止资源名重复。

示例

  • 用户组件(User),关于界面布局文件命名为:user_activity_about.xml
  • 用户组件(User),关于界面标题字符串命名为:user_about_title
  • 会员卡组件(MembershipCard),会员卡详情界面布局文件,文件名为:membership_card_activity_detail
  • 会员卡组件(MembershipCard),会员卡详情界面标题字符串,文件名为:membership_card_detail_title

类名

对于类名没必要增加前缀,比如 UserAboutActivity,因为对资源文件和资源变量增加前缀主要是为了避免重复定义资源导致资源被覆盖的问题,而上面的包名命名规范已经避免了类重复的问题,直接命名 AboutActivity 即可。

全局管理App环境

App 环境一般分为开发、测试和生产环境,不同环境下使用的网络请求地址大概率是不一样的,甚至一些UI都不一样,在打包的时候手动修改很容易有遗漏,产生不必要的 BUG。应当使用 buildConfigField 在打包的时候将当前环境写入 App 中,在代码中根据读取环境变量,根据不同的环境执行不同的操作。

示例

准备工作

App 壳 的 build.gradle 中给每个buildType 都配置 APP_ENV

android {
    ...
    buildTypes {
        debug {
            buildConfigField "String", "APP_ENV", '\"dev\"'
            ...
        }
        release {
            buildConfigField "String", "APP_ENV", '\"release\"'
            ...
        }
        ctest {
            initWith release

            buildConfigField "String", "APP_ENV", '\"test\"'
            matchingFallbacks = ['release']
        }
    }
}

注意:测试环境的 buildType 不能使用 test 作为名字,Android Studio 会报 ERROR: BuildType names cannot start with 'test',这里在 test 前增加了一个 c

commontool 路径下创建一个App环境工具类:

object AppEnvTool {

    /** 开发环境 */
    const val APP_ENV_DEV = "dev"
    /** 测试环境 */
    const val APP_ENV_TEST = "test"
    /** 生产环境 */
    const val APP_ENV_RELEASE = "release"

    /** 当前App环境,默认为开发环境 */
    private var curAppEnv = APP_ENV_DEV

    fun init(env: String) {
        curAppEnv = env
    }

    /** 当前是否处于开发环境 */
    val isDev: Boolean
        get() = curAppEnv == APP_ENV_DEV

    /** 当前是否处于测试环境 */
    val isTest: Boolean
        get() = curAppEnv == APP_ENV_TEST

    /** 当前是否处于生产环境 */
    val isRelease: Boolean
        get() = curAppEnv == APP_ENV_RELEASE

}

Application 中初始化App环境工具类:

class DemoApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        
        // 初始化App环境工具类
        AppEnvTool.init(BuildConfig.APP_ENV)
        ...
    }
}

使用App环境工具类

这里介绍根据App环境使用不同的网络请求地址:

object CommonApi {

    // api开发环境地址
    private const val API_DEV_URL = "https://demodev.taylorzhang.cc/api/"
    // api测试环境地址
    private const val API_TEST_URL = "https://demotest.taylorzhang.cc/api/"
    // api生产环境地址
    private const val API_RELEASE_URL = "https://demo.taylorzhang.cc/api/"
    // api地址
    val API_URL = getUrlByEnv(API_DEV_URL, API_TEST_URL, API_RELEASE_URL)

    // H5开发环境地址
    private const val H5_DEV_URL = "https://demodev.taylorzhang.cc/m/"
    // H5测试环境地址
    private const val H5_TEST_URL = "https://demotest.taylorzhang.cc/m/"
    // H5生产环境地址
    private const val H5_RELEASE_URL = "https://demo.taylorzhang.cc/m/"
    // H5地址
    val H5_URL = getUrlByEnv(H5_DEV_URL, H5_TEST_URL, H5_RELEASE_URL)

    private fun getUrlByEnv(devUrl: String, testUrl: String, releaseUrl: String): String {
        return when {
            AppEnvTool.isDev -> devUrl
            AppEnvTool.isTest -> testUrl
            else -> releaseUrl
        }
    }
}

打包

通过不同的命令打包,打出对应的App环境包:

# 打开发环境包
./gradlew clean assembleDebug

# 打测试环境包
./gradlew clean assembleCtest

# 打生产环境包
./gradlew clean assembleRelease

全局管理版本信息

项目中的 module 变多之后,如果要修改第三方库和App使用的SDK版本是一件很蛋疼的事情。应当建立一个配置文件进行管理,其他地方使用配置文件中设置的版本。

示例

在项目根目录下创建一个配置文件 config.gradle,里面放版本信息:

ext {
    compile_sdk_version = 28
    min_sdk_version = 17
    target_sdk_version = 28

    arouter_compiler_version = '1.2.2'
}

在项目根目录下的 build.gradle 文件中的最上方使用以下代码引入配置文件:

apply from: "config.gradle"

创建 module 后,修改该 module 中的 build.gradle 文件,将 SDK 版本默认值换成配置文件中的变量,按需添加第三方依赖,并使用 $ + 配置文件中的变量作为第三方库的版本:

android {
    ...
    compileSdkVersion compile_sdk_version

    defaultConfig {
        ...
        minSdkVersion min_sdk_version
        targetSdkVersion target_sdk_version
    }
}

dependencies {
    ...
    kapt "com.alibaba:arouter-compiler:$arouter_compiler_version"
}

混淆

混淆文件不应该在 App 壳中集中定义,应当在每个 module 中各自定义自己的混淆。

示例

这里介绍配置 user-impl 的混淆,先在 user-implbuild.gradle 中配置消费者混淆文件:

android {
    defaultConfig {
        ...
        consumerProguardFiles 'proguard-rules.pro'
    }
}

proguard-rules.pro 文件中写入该 module 的混淆:

# 实体类
-keepclassmembers class cc.taylorzhang.demo.user.entity.** { *; }

总结

组件化开发应当遵守"高内聚,低耦合"的原则,尽量少的对外暴露细节。如果用一句话来总结的话,就是代码和资源能放 module-impl 里面的就都放在 module-impl,因为代码隔离问题实在不能放 module-impl 里面的才放 module-api,最后因为涉及到循环依赖问题的才往 common 中放。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容

  • mean to add the formatted="false" attribute?.[ 46% 47325/...
    ProZoom阅读 2,665评论 0 3
  • 概述 组件化缘由 记得刚开始接触Android开发的时候,只知道MVC分层架构,而且感觉Model,View以及C...
    wustor阅读 1,853评论 0 12
  • 概述 组件化缘由 记得刚开始接触Android开发的时候,只知道MVC分层架构,而且感觉Model,View以及C...
    Simplelove_f033阅读 747评论 0 0
  • 畸形的网红文化,不堪的网络直播,败坏的社会风气,颠倒的是非观念。当今社会你说的如果是对的,那么大部分人就会认为它是...
    云端的ren阅读 227评论 0 0
  • 消失的童年 杭州客 成人们努力于《创世纪》里的那句话:“我将按我的想象来创造人”。成人们这种想替代上帝的...
    秦淮书生阅读 306评论 0 1