android-- 按需打包的框架搭建--新手教程
阅读原文时间:2023年07月08日阅读:9

1, 新建项目VariantTest

2, 生成keystore

可以看到, 默认的build variant只有debug一种

当我试图选release的时候,发现报错了

什么错呢

大致意思是说我们的app没有签名

我们知道签名需要一个keystore, 那么作为一个个人开发者,怎么获取keystore呢?

studio给我们提供了创建keystore的方式:

现在我们已经有了keystore, 那么下一步就是给项目添加签名信息

加完这些以后同步一下, 我们看到已经可以build release app了

以为这就大功告成了吗? 点击installRelease,

….几秒钟之后, 我得到了一个error

Execution failed for task ':app:installRelease'.
> java.util.concurrent.ExecutionException: com.android.builder.testing.api.DeviceException: com.android.ddmlib.InstallException: INSTALL_FAILED_UPDATE_INCOMPATIBLE: Package com.example.varianttest signatures do not match the previously installed version; ignoring!

意思是说, 当前试图安装的应用(com.example.varianttese)的签名和之前安装的不匹配。 (因为我之前已经安装了一个debug app), 虽然这次装的是release, 但因为没改包名,所以被认为是同一个app。

这里也体现了android的应用签名机制。

那就改下包名吧:

如果我们的app只有debug和release两种, 那么完全可以在buildType/release下面声明一个不同的applicationId

但是鉴于我们后面还需要添加多个variants, 因此我们新建一个gradle文件来处理包名-

app_ids.gradle

android.applicationVariants.all { variant ->

def buildType = variant.buildType.name  
def applicationId = "com.example.varianttest"

if (buildType.toLowerCase().contains("release")){  
    applicationId += ".release"  
}

variant.mergedFlavor.setApplicationId(applicationId)  

}

然后, 在app/build.gradle 文件头部去引用它:

apply from: '../app_ids.gradle'

同步一下, 再点击installRelease, 很快我手机上就有了两个app-- VariantTest

这当然是不能接受的, 因为它两长得一模一样,我完全分不清。

怎么去改app名字呢? 我们知道app名字定义在manifest中, 所以我们很容易想到新建一个manifest文件for release

只需要在src下面新建release目录, 放入manifest。 完全不需要其他的配置, 编译release app时就会读取release目录目录下的manifest并和默认manifest合并。

tools: replace的作用就是告诉编译器,需要将该属性替换


<application  
    android:allowBackup="true"  
    android:icon="@mipmap/ic\_launcher"  
    android:label="@string/app\_name\_release"  
    android:roundIcon="@mipmap/ic\_launcher\_round"  
    android:supportsRtl="true"  
    android:theme="@style/Theme.VariantTest"  
    tools:replace="android:label">  
</application>  

如此操作之后, 我们得到了两个名字不一样的app, 同理也可以改app图标, 这里不再演示。

3,从cert/prod的角度构建不同的app

现在为止我们得到debug/release两个app, 通常来说,debug app不做混淆, 会显示一些我们需要的log,并且可以断点调试

如果我们只是平时自己写着玩,这个buildType就够了。

但是对于绝大多数app来说,都不可避免地要使用网络和server交互。同一条请求,测试环境和生产环境要用到不同的domain,传入不同的参数。 或者有的功能我们希望只在测试app中开放

这个时候老板就希望我们能给build variants加上cert/ production两种

同时在开发过程中, app端和server端往往同步开工, 那么在api没有ready的情况下我们也希望有个mock环境能供我们调试native UI

那就开始搞吧

新建一个gradle文件-- environment_flavors.gradle: 定义了cert, prod, mock三种环境

android {
productFlavors {
cert {
dimension 'environment'
}
production {
dimension 'environment'
}
mock {
dimension 'environment'
}
}
}

然后在app/build.gradle首部添加

apply from: '../environment_flavors.gradle'

并且申明 flavors: environment:

同步一下, 现在我们已经可以看到这些variants:

我们也希望它们有不同的包名, 这样我可以在一台device上同时安装多个variants

因此我们修改app_ids.gradle, 修改后的代码如下:(红色为本次修改的部分)

android.applicationVariants.all { variant ->

def buildType = variant.buildType.name  
def applicationId = "com.example.varianttest"  
def environmentName = variant.productFlavors\[0\].name  

if (environmentName == "cert") {  
    applicationId += ".cert"  
}  
if (environmentName == "production") {  
    applicationId += ".prod"  
}  
if (environmentName == "mock") {  
    applicationId += ".mock"  
}  

if (buildType.toLowerCase().contains("release")){  
    applicationId += ".release"  
}  

variant.mergedFlavor.setApplicationId(applicationId)  

}

包名不同保证了我们可以同时安装, 此外我们也希望这些app有不同的名字,否则装在一起我们完全不知道谁是谁

这时我们已经不大可能为每个variant都去创建一个manifest了, 怎么办呢?我们可以使用占位符来解决

manifest文件中:

android:label="@string/app_name${appNameEnv}${appNameBuildType}"

再修改app/build.gradle:

defauleConfig{

manifestPlaceholders = \[appNameEnv: "", appNameBuildType: ""\]

}

buildTypes {
release {

manifestPlaceholders.appNameBuildType = '_release'
}
}

然后 environment_flavors.gradle:

android {
productFlavors {
cert {
dimension 'environment'
manifestPlaceholders.appNameEnv = '_cert'
}
production {
dimension 'environment'
manifestPlaceholders.appNameEnv = '_prod'
}
mock {
dimension 'environment'
manifestPlaceholders.appNameEnv = '_mock'
}
}
}

同步一下, 现在我们已经可以得到6个build了

但是到目前为止, cert/prod/mock的内容完全一样,根本体现不出应有的价值,那么接下来就是最关键的操作了, 怎么让不同的build去关联不同的环境呢?

我们很容易想到通过BuildConfig在代码中获取到当前的build flavors, 然后可以据此判断,设置不同的环境。如下面的代码:

当不同环境之间只有极少数区别且不涉及频繁改动的时候, 这种方式当然也可以。 但缺点是耦合性太高,不利于后期的维护和扩展

因此在项目中, 我更偏向于使用一个json文件来描述不同的配置, 比如我们之前在environment_flavors.gradle中声明了3种flavors: cert/mock/production

那么对应的,我们可以在app/src下面创建3个assets文件夹,分别放入apiConfig.json

apiConfig.json (mock和production中host的值分别对应.mock和.production)

定义data class ApiConfiguration

data class ApiConfiguration(
val host: String
)

创建一个工具类读取assets中的json文件并转换为ApiConfiguration对象

interface AssetsLoader {

fun getApiConfiguration() : ApiConfiguration  

}

class ApplicationAssetsLoader(private val configLoader: ConfigurationLoader) : AssetsLoader {

override fun getApiConfiguration(): ApiConfiguration {  
    return loadConfig("apiConfig.json")  
}  

private inline fun <reified T : Any> loadConfig(fileName: String): T {  
    return configLoader.requireConfig(fileName)  
}  

}

interface ConfigurationLoader {
fun loadConfig(fileName: String, type: KClass): T?
}

inline fun ConfigurationLoader.requireConfig(
fileName: String
): T {
return loadConfig(fileName, T::class)
?: throw IllegalStateException("$fileName config file does not exist")
}

class JsonConfigurationLoader(
val gson: Gson,
val assets: AssetManager
) : ConfigurationLoader {

override fun <T : Any> loadConfig(fileName: String, type: KClass<T>): T? {  
    return try {  
        BufferedReader(InputStreamReader(assets.open(fileName)))  
                .use { reader -> gson.fromJson(reader, type.java) }  
    } catch (e: IOException) { // Exception is thrown if file is missing or couldn't be read  
        null  
    }  
}  

}

这里其实可以写得很简单, 本例中因为考虑到后面不同variants可能还要读取一些不同的文件类型, 所以抽象出了接口。

因为我们之前在app/build.gradle中已经声明了:

flavorDimensions 'environment'

所以只要上面我们新建的那三个文件夹的名字和environment_flavors.gradle中声明的一致,就不再需要其他的任何配置, 每个buildVariants都可以读到正确的json文件

简单测试一下,代码如下

本例中使用了MVVM, 数据驱动UI。分别跑一下cert/mock/prod app, 可以看到它们都拿到了正确的环境配置

4, Mock环境搭建

看到这里, 聪明的小伙伴们肯定会有个疑问, mock环境通常供开发者调试ui使用, 并不涉及和api的交互, 所以自然也就不需要api domain之类的东西

那么怎么实现mock呢?

比如,现在我们有一条网络请求getMoney,要去server拿response显示在home页面

于是我们根据api同事预先提供的返回数据格式写了数据类

data class GetMoneyResponse(
val name: String,
val count: Int,
val type: String,
val currency: String
)

接口GetMoneyRepository:

interface GetMoneyRepository {
fun getMoney(): GetMoneyResponse
}

接口实现类:

class GetMoneyRepositoryImpl() : GetMoneyRepository {
override fun getMoney(): GetMoneyResponse {
//这里应该要去call api
//本例省去了这个步骤
return GetMoneyResponse("name", 0, "type", "currency")
}
}

然后在viewModel中调用

private val _response = MutableLiveData().apply {
value = GetMoneyRepositoryImpl().getMoney()
}

val response: LiveData = _response

在fragment 显示

private fun getData(){
homeViewModel.response.observe(viewLifecycleOwner, {
responseView.text = it.name + "通过:" + it.type + "赚到了:"+ it.count + it.currency

})  

}

至此, native部分就写完了。可是在api迟迟没有ready的情况下, 我们怎么用mock数据来测试呢?

上文中, 我们已经为mock环境创建了mock文件夹,并放入了mock build会用到的assets文件

现在我们在该文件夹下新建两个子目录with和without

将类GetMoneyRepositoryImpl移到without目录下, 我们希望真实环境(cert/prod)下可以编译这个文件

然后在with目录下再创建一个GetMoneyRepositoryImpl供mock环境使用

class GetMoneyRepositoryImpl() : GetMoneyRepository {

override fun getMoney(): GetMoneyResponse {  
    //因为这个类供mock使用, 因此我们可以直接返回我们想要的任何response  
    //通常的做法是在mock/assets下加入我们想要的response文件,如 getMoneyResponse.json, 然后读取assets  
    //本例中简化了这一步  
    return GetMoneyResponse("张三", 500, "搬砖", "人民币")  
}  

}

所以现在的目录就变成了这样

注意, 这里的两个实现类GetMoneyRepositoryImpl拥有完全相同的类名和包名,只是方法实现不同

因此, 我们会发现viewModel里面报错了, 因为编译器不允许同时存在两个一样的类

所以下一步,我们就需要告诉编译器,什么时候该用哪个类

在app/build.gradle 下面添加如下描述:

android {
String mockSources = "src/mock/with"
String noMockSources = "src/mock/without"
sourceSets {
main {
java.srcDirs += ['src/main/kotlin']
}
cert {
java.srcDirs += [noMockSources]
}
mock {
java.srcDirs += [mockSources]
}
production {
java.srcDirs += [noMockSources]
}
}
}

这段的作用就是告诉编译器,mock环境就编译“src/mock/with”下面的代码, 否则就编译“src/mock/without”下的代码

大功告成, 我们分别安装cert和mock app验证一下:

5, 多维变体

就当我觉得可以松一口气的时候,老板又提出了新需求, 随着公司业务的不断扩展, 我们的app在全球范围内都有了客户群,各种风格/功能上的差异已经不仅仅是改改copy就能解决的了。所以老板希望我们能再增加一个国家的维度, 给不同的国家提供不通的app

本质上讲, 这和上文说到的environment变体并没有什么不同, 只是新增一个维度而已,  下面我们来看具体实现

新建country_flavors.gradle, 为了简单,我们只声明了china和uk两个国家

android {
productFlavors {
china {
dimension 'country'
}
uk {
dimension 'country'
}
}
}

在app/build.gradle中引用这个文件

apply from: '../country_flavors.gradle'

并修改flavorDimensions, 增加country维度

flavorDimensions 'country', 'environment'

修改app_ids.gradle,让不同的国家拥有不同的包名

android.applicationVariants.all { variant ->

def buildType = variant.buildType.name  
def countryName = variant.productFlavors\[0\].name.toUpperCase()  
def environmentName = variant.productFlavors\[1\].name  
def appIdCountry = AppId.valueOf(countryName)  
def applicationId = appIdCountry.appId

if (environmentName == "cert") {  
    applicationId += appIdCountry.certSuffix  
}else if (environmentName == "production") {  
    applicationId += appIdCountry.productionSuffix  
} else if (environmentName == "mock") {  
    applicationId += appIdCountry.mockSuffix  
}

if (buildType.toLowerCase().contains("release")){  
    applicationId += appIdCountry.RELEASE\_SUFFIX  
}

variant.mergedFlavor.setApplicationId(applicationId)  

}

enum AppId {
CHINA("com.variant.china"),
UK("com.variant.uk")

public final String appId  
private final static String MOCK\_SUFFIX = ".mock"  
private final static String CERT\_SUFFIX = ".cert"  
public final static String RELEASE\_SUFFIX = ".release"  
private final static String PROD\_SUFFIX = ".prod"  
public final String certSuffix  
public final String mockSuffix  
public final String productionSuffix

AppId(String appId, String certSuffix = CERT\_SUFFIX, String prodSuffix = PROD\_SUFFIX, String mockSuffix = MOCK\_SUFFIX) {  
    this.appId = appId  
    this.certSuffix = certSuffix  
    this.mockSuffix = mockSuffix  
    this.productionSuffix = prodSuffix  
}  

}

同时, 在app/src目录下新建china/res/values/strings.xml :

Variant China Cert Debug Variant China Cert Release Variant China Mock Debug Variant China Mock Release Variant China Prod Debug Variant China Prod Release 主页 活动 通知

  和 uk/res/values/strings.xml:

Variant UK Cert Debug Variant UK Cert Release Variant UK Mock Debug Variant UK Mock Release Variant UK Prod Debug Variant UK Prod Release Home Dashboard Notifications

  这样,不同的app也可以读到不同的copy,显示不同的包名

注意这里和android 的copy 国际化不太一样, 没有根据local来确定copy, 而是根据我们自己设置的build variant, 处理更加灵活

6, 现在我们已经可以从country的维度来build出不同的app了, 那么接下来, 怎么让不同的country有不同的功能呢?

类似于上文第4步, 在app/src/china以及app/src/uk目录下新建assets文件夹, 加入featureConfig.json (China配置为true, uk配置false)

{
"showImage": true
} 

我们根据该config来决定要不要显示首页的一张图片

private fun initImageView(){
homeViewModel.showImage.observe(viewLifecycleOwner, {showImage ->
if (showImage){
imageView.visibility = View.VISIBLE
} else {
imageView.visibility = View.GONE
}
})
homeViewModel.getFeatureConfiguration(requireContext())
}

json文件的读取也与第4步相似,不再赘述。我们直接来看结果, 下图中左边是china, 右边是uk

7,  按需打包

看到这里, 我们就掌握了多维app构建的基本方法,当然我们还可以增加更多的维度,比如按应用市场, baidu/huawei/xiaomi 等等, 但基本原理都是一样的。

然而,就当我准备关电脑下班时, 老板又找到了我, 提出了新需求:

在我等加班几年的努力下, 我们的app功能不断增多, 引入了大量的第三方库, 导致的结果就是app size不断增大, 眼看就要突破google设置的150M生死线, 所以给app瘦身就成了当前迫在眉睫的问题。

经粗略统计, 我们共引入了几十个第三方库, 但是并非所有的app都需要这些库, 所以我们应该通过country来配置依赖

这里我们以 okhttp为例, 假设china 需要okhttp在首页加载一张图片, 但uk全程都不需要

那我们先新建country_implementations.gradle文件, 并在app/build.gradle中引用

dependencies {
chinaImplementation "com.squareup.okhttp3:okhttp:4.4.0"
chinaImplementation "com.squareup.okhttp3:okhttp-urlconnection:4.4.0"
}

注意这里的 chinaImplementation  意思就是只给china 添加依赖。

不用担心编译器找不到这个方法, 因为我们之前已经声明了名为 country的flavor, 包括了china和uk

所以编译器完全可以识别这个命令, 就跟我们平常用的testImplementation, debugImplementation一样

接下来就是怎么调用的问题了, 以前我们直接在整个工程下添加依赖, 这样项目里的任何地方都可以获取到该依赖

但现在,因为我们只给china 加了,所以不能在工程代码里直接调用。否则,当你build uk app时根本找不到

比如,当我build uk variant时, 这行代码是报错的

怎么解决呢?

其实思路和上文中搭建mock环境一样

在src下新建journey/okhttp/main/java目录, 分别放入两个同名的工具类HttpJourney

在with/main/java目录下的文件里, 我们实现了我们要用到的http journey的一些方法

在without/main/java目录下的文件里, 只需要定义空方法,或者直接throw exception

然后在fragment里调用

private fun initImageView(){
homeViewModel.showImage.observe(viewLifecycleOwner, {showImage ->
if (showImage){
HttpJourney().getImageViaHttp()
imageView.visibility = View.VISIBLE
} else {
imageView.visibility = View.GONE
}
})
homeViewModel.getFeatureConfiguration(requireContext())
} 

注意这里 showImage 在uk config里的配置一定是false .

代码部分加完了, 最后一步就是告诉编译器, china和uk分别要编译哪些journey的代码

我们继续在country_implementations.gradle中添加如下代码

enum Journey {
HTTP_JOURNEY("okhttp")

public final String sourceSetName

Journey(String sourceSetName) {  
    this.sourceSetName = sourceSetName  
}  

}

static def addJourneySources(String country, List journeys, sourceSets) {
def included = Journey.values().toList().intersect(journeys)
def excluded = Journey.values() - included
def sourceSet = sourceSets.findByName(country)

for (journey in included) {  
    sourceSet.java.srcDirs += "src/journey/${journey.sourceSetName}/with/main/java"  
}  
for (journey in excluded) {  
    sourceSet.java.srcDirs += "src/journey/${journey.sourceSetName}/without/main/java"  
}  

}

android {
sourceSets { container ->
china {
ArrayList journeys = new ArrayList([Journey.HTTP_JOURNEY])
addJourneySources(name, journeys, container)
}
uk {
ArrayList journeys = new ArrayList([])
addJourneySources(name, journeys, container)
}
}
}  

代码很简单,相信大家都能看懂, 这里就不废话了

看看结果,分别build和china和uk的cert app

umm, 效果还是有的