Android Studio项目Gradle构建实践

参考

名词

  • 构建类型(BuildType),编译时的类型,如debug, release
  • 产品风味(ProductFlavor),不同的产品特征,可以有不同的包名等等。
  • 构建变体(BuildVariant),每一个特定唯一确定版本apk都是一个构建变体的产物,其由构建类型和产品风味组成。
  • APG,全称是Android Plugin for Gradle,google为使用gradle构建而开发的插件。

1 一个典型的Android Studio 项目

1.1 项目结构

一个新建的Android Stuido项目结构如下:

项目结构

包含三个.gradle文件:

  • settings.gradle 文件对应脚本执行时的setting对象,该文件最先被解析和执行,一些通用的初始化操作可以放在这里执行,项目包含多个子工程或者模块时,必须在该文件中include,这也是其最重要的功能之一。新建项目默认的settings.gradle
include ':app'

这里我想在脚本刚执行时打印项目的存放路径操作:

String projectDir = rootProject.projectDir.getAbsolutePath();
println projectDir
include ':app'
  • 项目根目录下的build.gradle,对应脚本执行时的rootProject对象,一般不做具体的模块构建操作,用于指定项目所依赖的远程仓库和使用的Gradle plugin 插件版本,适用与所有的子工程或者模块。
// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    //jcenter一个著名的远程代码仓库
    repositories {
        jcenter()
    }
    
    
    dependencies {
        classpath 'com.android.tools.build:gradle:2.2.3'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}
  1. 上面指定远程仓库的作用就是在需要依赖的库在本地找不到时,会到该仓库中去寻找并自动下载。
  2. 依赖的构建插件,注意该插件不是Gradle的版本,是插件的版本,由google开发
  • 每个子项目或模块下单独的build.gradle脚本文件,在这里指定各自依赖的SDK,库属性等等,这也是我们编译的脚本的主体。
apply plugin: 'com.android.application'

android {
    compileSdkVersion 25
    buildToolsVersion "25.0.1"
    defaultConfig {
        applicationId "com.inpor.fmcdevicedemon"
        minSdkVersion 14
        targetSdkVersion 25
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
    })
    compile 'com.android.support:appcompat-v7:25.0.1'
    testCompile 'junit:junit:4.12'
}
  • apply plugin 指定要加载的插件,这里是一个应用,所以加载com.android.application插件,注意这个插件是我们上面使用的google开发的com.android.tools.build:gradle:2.2.3中携带的。

  • android闭包来自于google插件,这里查看其DSL文档:http://google.github.io/android-gradle-dsl/

1.2 Gradle Project

我们的AS项目对于Gradle而言就是一个个Gradle项目,而Gradle项目对于我们而言就是一个个task构成的,我们可以点击Android Studio的最右边的Gradle工具栏,可以查看其项目结构。

gradle project

比如上面我们点开other目录,双击第一个任务,此时就可以直接这个任务,生成一个apk。

gradle project2

2 配置基本编译参数

2.1 基本使用

这里主要是指设置编译时指定的SDK、bulidtools版本,包名,应用版本号等等,注意这里面定义的属性会覆盖AndroidMainfest.xml文件中定义的。

//编译时的SDK版本号
compileSdkVersion 25

//编译时指定的buildtools版本
buildToolsVersion "25.0.1"

defaultConfig {
    //应用的包名
    applicationId "com.inpor.fmcdevicedemon"
    
    //指定应用可以安装到的系统的最低版本,这里14对应的是android 4.0
    minSdkVersion 14
    
    //运行时指定使用的sdk的版本,主要针对当存在多个sdk版本时,优先使用的SDK版本
    targetSdkVersion 25
    
    //应用版本号
    versionCode 1
    versionName "1.0"
    
    //执行单元测试时指定的Runner,在正式打包时并不会使用到
    testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}

2.2 提取公用字段定义到其他文件中

前面说过我们可以把一个通用的属性存放到项目根目录下的build.gradle中。

//使用ext表示导出
ext {
    compileSdk = 25
    buildTools = "25.0.1"
    targetSdk = 25
    minSdk = 14
}

然后在app的build.gradle文件中使用定义的通用属性

compileSdkVersion rootProject.ext.compileSdk
buildToolsVersion rootProject.ext.buildTools
defaultConfig {
    applicationId "com.inpor.fmcdevicedemon"
    minSdkVersion rootProject.ext.minSdk
    targetSdkVersion rootProject.ext.targetSdk
    versionCode 1
    versionName "1.0"
    testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}

如果是多项目关联,把一些共有的属性提取出来就很有用了。

2.3 使用resConfigs只打包需要的资源

只打包我们需要的资源。我们知道google给我们的apk提供了国际化支持,如适应不同的屏幕分辨率的drawable资源,还有适应不同语言的字符串资源等等,但是在很多情况下我们只需要一些指定分辨率和语言的资源就可以了,这个时候我们可以使用resConfigs方法来配置。

defaultConfig中添加如下配置之后

defaultConfig {
    .....
    // 过滤,对于国际化支持只打包中文资源,和"xxhdpi"
    // 注意如果在这里指定了dpi,则flavor中不能指定的dpi与这里必须一致否则会报错
    resConfigs "zh-rCN", "xhdpi"
}

在添加resConfigs之前,反编译的res目录截图:

未过滤图片

在添加上述resConfigs配置之后,反编译res目录:

过滤后的图片

注意:

  • 使用resConfigs并不会过滤默认的drawable, values目录,这是为了保证App在任何时候都有一个默认的可选值。
  • resConfigs也可以在后面要讲到的productFlavor中也可以使用。

3 signingConfigs(Apk签名配置)

3.1 配置不同的签名

在默认情况下,AS中编译apk使用的是SDK中的Debug签名,不需要显式的指定签名配置项,在signingConfigs的闭包中我们可以自定义多个签名配置,一个典型的签名配置:

signingConfigs {
    
    //debug签名
    debug {
        //签名秘钥库文件的存放的位置,这里使用的是相对路径
        storeFile file('sign/debug.keystore')
        
        //秘钥库的访问密码
        storePassword 'android'
        
        //别名,因为一个密码库可以供多个项目使用,所以别名不同,最后的签名也是不同的。
        keyAlias 'androidreleasekey'
        
        //别名的私钥密码
        keyPassword 'android'
    }
    
    
    release {
        storeFile file('sign/platform.keystore')
        storePassword 'android'
        keyAlias 'androidreleasekey'
        keyPassword 'android'
    }
}

3.2 从指定文件加载签名和秘钥

如果希望不在build.gradle中暴露自己的签名秘钥,可以将这些参数放到一个专门的文件中,比如在项目的根目录下添加一个keystore.properties文件。

//test
debugStoreFile=sign/debug.keystore
debugStorePassword=android
debugKeyAlias=androidreleasekey
debugKeyPassword=android

//release
releaseStoreFile=sign/platform.keystore
releaseStorePassword=android
releaseKeyAlias=androidreleasekey
releaseKeyPassword=android

在app模块的build.gradle中,解析这个文件

// Create a variable called keystorePropertiesFile, and initialize it to your
// keystore.properties file, in the rootProject folder.
def keystorePropertiesFile = rootProject.file("keystore.properties")

// Initialize a new Properties() object called keystoreProperties.
def keystoreProperties = new Properties()

// Load your keystore.properties file into the keystoreProperties object.
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))

android{
    .....
}

修改signConfigs闭包,引用文件中定义的属性

signingConfigs {
    debug {
        keyAlias keystoreProperties['debugKeyAlias']
        keyPassword keystoreProperties['debugKeyPassword']
        storeFile file(keystoreProperties['debugStoreFile'])
        storePassword keystoreProperties['debugStorePassword']
    }
    
    release{
        keyAlias keystoreProperties['releaseKeyAlias']
        keyPassword keystoreProperties['releaseKeyPassword']
        storeFile file(keystoreProperties['releaseStoreFile'])
        storePassword keystoreProperties['releaseStorePassword']
    }
}

4 编译类型(buildTypes

在Android studio中我们可以自定义不同的编译类型,如调试版本,发行版本,在不同的版本中可以配置不同的参数与添加属性,工程自带有一个debug编译类型,另外用户无法自定义为testtype,它已经被单元测试占用。

比如下面我定义了三个不同的buildType,分别设置不同的属性值

buildTypes {

    debug {
        //指定签名文件的配置,不指定则使用SDK中默认的debug签名
        signingConfig signingConfigs.debug
        
        //压缩对齐,提高运行时的效率,也可以使用zipAlignEnabled true
        setZipAlignEnabled(true)
        
        //可以调试
        debuggable true
        
        //jni可调试
        jniDebuggable true
        
        //渲染脚本可调试
        renderscriptDebuggable true
    }


    //在发行版本中,不允许调试,并且添加代码混淆
    release {

        setZipAlignEnabled(true)
        debuggable false
        jniDebuggable false
        renderscriptDebuggable false
        
        //指定签名文件为release签名,注意非debug,如果不指定签名,则打出来的包不会签名
        signingConfig signingConfigs.release
        
        //minifyEnabled表示代码是否可以压缩,裁剪优化,需要配合其他的工具一起使用,如proguard
        //添加代码混淆,注意添加混淆时,必须将minifyEnabled 置为true,否则混淆不生效
        //同样如果没有使用代码混淆必须置为false,否则编译失败
        minifyEnabled true
        proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'

    }
    
    //自定义一个type,不使用代码混淆,并且添加一个string资源到xml资源文件中
    custom{
        zipAlignEnabled true
        debuggable false
        jniDebuggable false
        renderscriptDebuggable false

        //指定签名文件为release签名
        signingConfig signingConfigs.release

        //添加一个字符资源到values/strings.xml文件中,目前无法指定资源的语言类别
        resValue "string", "custom_name", "测试用户"
    }

}

上面的minifyEnabled还可以配合shrinkResources属性使用,移除没有使用到的资源文件。

buildTypes {
    custom {
        ......
        minifyEnabled true
        shrinkResources true
        ......
    }
}

在实际测试中发现,上述裁剪可以剪裁布局、图片、菜单,但是不会移除values。

注意shrinkResources优化并不一定会删除没有用到的文件,在我的实际测试中,它会图片、布局变成最小,没有删除它们

当我们需要动态加载资源时,需要在不要使用该优化,否则可能会出现运行时报错或者显示效果不正确的问题,如果要使用该优化可以在res/raw/keep.xml中进行特定资源的保持或优化,如下例子不优化layout/test_layout

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:keep="@layout/test_layout"/>

参考(官网持有资源一章 ):https://developer.android.google.cn/studio/build/shrink-code.html#keep-resources

5 splits(拆分只包含某些需要属性的apk)

splits

其作用是将当前配置的版本的apk分裂生成多个只包含指定属性的Apk,目前在google给我们提供根据language, abi, density进行拆分。

//过滤只打包英文和简体中文的资源
splits{
    //设置根据language拆分测试未通过,应该是字符串的表现形式不对
//        language{
//            enable true
//            include "values-zh-rCN"
//            include "zh-rCN"
//        }

    density{
        enable true
        reset()  // Clears the default list from all densities to no densities.
        include "mdpi", "xxhdpi" // Specifies the two densities we want to generate APKs for.
    }
}

上面的配置编译之后会生成3个Apk:

app-mdpi-custom.apk  //裁剪掉大部分非mdpi资源的apk

app-xxhdpi-custom.apk  //裁减掉大部分非xxdpi资源的apk

app-universal-custom.apk //未做任何裁剪的apk
              

参考Android apk splits 官方文档:https://developer.android.google.cn/studio/build/configure-apk-splits.html

6 PackagingOptions(指定添加/移除某些文件到最终的apk中)

首先看其DSL结构图。



PackagingOptions不同于resConfigs,后者过滤某些资源目录,前者是在打包Apk的时候(已经执行过编译了)排除一些文件,在实际测试中并不能用于过滤资源文件等等,更多是用于过滤一些与工程没有直接关系的文件(声明、版本控制等等)。

  • First-pick,如果要添加的文件已经存在于Apk中,则会被忽略,如果有多个路径于指定的pattern,只添加第一个。
  • Merge,合并的意思,如果文件不存在,则添加,如果文件已经存在,则合并内容。
  • Exclude,不包含的内容,默认以下的内容不会被打包到Apk中:
/META-INF/LICENCE
/META-INF/LICENCE.txt
/META-INF/NOTICE
/META-INF/NOTICE.txt
/LICENCE
/LICENCE.txt
/NOTICE
/NOTICE.txt
**/.svn/** (all .svn directory contents)
**/CVS/** (all CVS directory contents)
**/SCCS/** (all SCCS directory contents)
**/.* (all UNIX hidden files)
**/.*/** (all contents of UNIX hidden directories)
**/*~ (temporary files)
**/thumbs.db
**/picasa.ini
**/about.html
**/package.html
**/overview.html
**/_*
**/_*/**

PackagingOptions更多的用于去除编译时依赖不同的包时,含有相同的文件时,去除编译时的重复错误中。如:

//在打包时,移除一些许可,注意文档
packagingOptions {
    exclude 'META-INF/DEPENDENCIES.txt'
    exclude 'META-INF/NOTICE'
    exclude 'META-INF/NOTICE.txt'
    exclude 'META-INF/LICENSE'
    exclude 'META-INF/LICENSE.txt'
}

实际测试中还没有发现有其他的作用,待补充。

官方DSL文档链接:http://google.github.io/android-gradle-dsl/current/com.android.build.gradle.internal.dsl.PackagingOptions.html

7 lintOptions

lint检查工具是google开发的一款代码扫描工具,其主要用于扫描布局,未使用资源,国际化等等问题,其作用在这里不是我们关注的重点,其使用和配置方法请查看官方文档:https://developer.android.google.cn/studio/write/lint.html

这里我们要考虑的是link选项对我们打包的影响,要注意link检查抛出来的错误,并不会导致编译时候的错误,可能会导致运行时的错误,以是lintOptions的属性截图:

lintOptions

在Android Studio中默认下,link检查报错会导致编译中断,为了避免这个问题,我们可以在android闭包中添加如下代码:

android {
    ......
    lintOptions {
        //关闭编译release版本的lint检查
        checkReleaseBuilds false
        
        //关闭link检查报错中断编译选项
        abortOnError false
    }
    ......
}

在日常研发中,我们应当频繁执行lint检查,以优化代码和提前暴露一些可能运行时报错的代码。

8 productFlavor(产品风味)

8.1 基本属性与方法

productFlavor与其说是产品风味还不如说是产品工厂,我们可以根据不同的需要,最终生成不同的apk。多渠道打包是productFlavor的最常用的功能之一。上面说到的defaultConfig我们可以认为一种简略的默认productFlavor,所以我们完全可以在自定义的productFlavor中覆盖defaultConfig中的任意配置项。

  • 基本属性
perpties
  • 方法与闭包


    method

参考:http://google.github.io/android-gradle-dsl/current/com.android.build.gradle.internal.dsl.ProductFlavor.html

从上面的属性与方法中我们发现可以设置包名,版本,混淆文件, NDK等等

8.2 示例

现在我创建多个productFlavor,它们具有不同的包名,不同的版本号

productFlavors{
    sky{
        //直接在原来的包名后加后缀
        applicationIdSuffix ".sky"
        
        //指定不同的版本
        versionName '1.2.0'
        versionCode 120
    }

    gavin{
        //重新命名包名
        applicationId "com.gavin.gradlebuilddemo"
        
        //指定不同的最小编译版本
        minSdkVersion 17
        targetSdkVersion 21
    }

    smith{
        
        applicationId "com.smith.gradlebuilddemo"
        
        //指定不同的resConfig
        resConfigs "zh-rHK",

        //添加resValue
        resValue 'string', 'smith', 'this is smith'
    }
}

考虑到一种情况,有时候我们有些公共的资源和配置是某些productFlavors公用的,我们希望把它们提取出来,减少重复,这个时候我们可以使用flavorDimensions来实现我们的需求。

//使用dimensions将一些公共的修改独立出来,可以重复使用,减小代码的重复
flavorDimensions 'type', 'common'
    
productFlavors{
    sky{
        dimension 'type'
        
        //直接在原来的包名后加后缀
        applicationIdSuffix ".sky"
        
        //指定不同的版本
        versionName '1.2.0'
        versionCode 120
    }

    gavin{
        dimension 'type'
        
        //重新命名包名
        applicationId "com.gavin.gradlebuilddemo"
        
        //指定不同的最小编译版本
        minSdkVersion 17
        targetSdkVersion 21
    }

    smith{
        dimension 'type'
        
        applicationId "com.smith.gradlebuilddemo"
        
        //指定不同的resConfig
        resConfigs "en", "hdpi"

        //添加resValue
        resValue 'string', 'smith', 'this is smith'
    }
    
    commonClient{
        dimension 'common'
        
        //添加resValue
        resValue 'string', 'common_client', 'this is common_client'
    }
    
    commonPrivate{
        dimension 'common'
        
        //指定一个私有的混淆规则
        proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules-private.pro'
    }
}

采用上述实现中,会将两两不同的dimension进行组合,最终生成我们需要的apk。比如app-sky-commonClient-debug.apk。

现在我们来计算一下最终可以生成的apk的数目,我们将flavorDimensions看成数组的话,最终可以生成的apk的数目为:

count = BuildType.size * flavorDimensions[0].size * ... flavorDimensions[n].size

这里的flavorDimensions[n].size是指每个DimensionsproductFlavors中的个数,比如上面的最终能够生成的apk个数就是:3 * 3(type)* 2(common) = 18。

8.3 资源替换

同样的我们也可以在src\main的同级目录下给每个productFlavors建立目录存放我们的特定资源。

srcFlavorDir

替换res资源

替换res资源采用的是合并的原则,即最终的资源是所有进行合并的资源的并集,出现资源ID重复的资源,采用优先级最高的那个,具体的优先级后面会讲到。

main/res/values/strings.xml中定义了这样的资源。

<resources>
    <string name="app_name">GradleBuildDemo</string>
    <string name="hello">hello world</string>
    <string name="enter_button_str">enter</string>
    <string name="cancel_button_str">cancel</string>
    <string name="input_tips">Please input the word you want</string>
    <string name="cancel_tips_msg">button is clicked</string>
</resources>

sky/res/values/strings.xml中重新定义了如下资源。

<resources>
    <string name="app_name">GradleBuildDemo_Sky</string>
    <string name="hello">hello world, sky</string>
    <string name="enter_button_str">enter sky help</string>
    <string name="cancel_button_str">cancel sky</string>
</resources>

最终合并的资源是这样的。

<resources>
    <string name="app_name">GradleBuildDemo_Sky</string>
    <string name="hello">hello world, sky</string>
    <string name="enter_button_str">enter sky help</string>
    <string name="cancel_button_str">cancel sky</string>
    <string name="input_tips">Please input the word you want</string>
    <string name="cancel_tips_msg">button is clicked</string>
</resources>

注意layout资源是以整个文件覆盖的方式合并的。

assets目录

assets目录中的文件是以整个文件覆盖的方式进行合并的。

java原代码目录

源码文件的合并与其他的不同,如果我们想在不同的变体中对一个类做不同的实现,那么我们就不能在main/java目录下定义这个类,只能在每个变体中单独定义,并且路径要一致,而且对一些变体进行组合时,同时也只能存在一份代码。

以下示例中,我分别在sky, gavin两个flavors中定义了HelpTestActivity类。

sky版本的HelpTestActivity类。

package com.sky.gradlebuilddemo.activity;

import android.graphics.Color;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.design.widget.Snackbar;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Button;

import com.sky.gradlebuilddemo.R;

/**
 * PACKAGE_NAME
 * [function]
 * [detail]
 * Created by Sky on 2016/10/24.
 * modify by
 */

public class HelpTestActivity extends AppCompatActivity {


    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_help_test);
        Button skyButton = (Button) findViewById(R.id.skyButton);

        //点击按钮弹出提示文案,
        skyButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                final Snackbar snackbar = Snackbar.make(HelpTestActivity.this.getWindow().getDecorView(),
                        "hello snackbar", Snackbar.LENGTH_LONG);
                snackbar.setAction("Change Color", new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        snackbar.getView().setBackgroundResource(R.color.colorPrimary);
                    }
                }).show();

            }
        });
    }
}

gavin版本的HelpTestActivity

package com.sky.gradlebuilddemo.activity;

import android.content.Context;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Adapter;
import android.widget.BaseAdapter;
import android.widget.ListAdapter;
import android.widget.ListView;
import android.widget.SimpleAdapter;
import android.widget.TextView;

import com.sky.gradlebuilddemo.R;


/**
 * PACKAGE_NAME
 * [function]
 * [detail]
 * Created by Sky on 2016/10/24.
 * modify by
 */

public class HelpTestActivity extends AppCompatActivity {


    private ListView listView;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_help_test);
        listView = (ListView) findViewById(R.id.msgListView);
        String[] msgs = getResources().getStringArray(R.array.listMsg);
        listView.setAdapter(new SimpleListAdapter(this, msgs));
    }

    private static class SimpleListAdapter extends BaseAdapter{

        private String[] data;

        private Context context;

        SimpleListAdapter(Context context, String[]data){
            this.data = data;
            this.context = context;
        }

        @Override
        public int getCount() {
            return data.length;
        }

        @Override
        public Object getItem(int position) {
            return data[position];
        }

        @Override
        public long getItemId(int position) {
            return 0;
        }

        @Override
        public View getView(int position, View convertView, ViewGroup parent) {
            if(convertView == null){
                convertView = LayoutInflater.from(context).inflate(R.layout.list_item_layout, null);
            }
            TextView  textView = (TextView) convertView.findViewById(R.id.itemTextView);
            textView.setText(data[position]);
            return convertView;
        }
    }


}

MainActivity中调用它。

package com.sky.gradlebuilddemo;

import android.content.Intent;
import android.support.design.widget.Snackbar;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;

import com.sky.gradlebuilddemo.activity.HelpTestActivity;

public class MainActivity extends AppCompatActivity {

    private static final String TAG = "MainActivity";
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button enterButton = (Button) findViewById(R.id.enter_button);

        enterButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                MainActivity.this.startActivity(new Intent(MainActivity.this, HelpTestActivity.class));
            }
        });

        .......
    }
}

9 sourceSets(资源集合的集合)

9.1 sourceSet与优先级

sourceSet就是所谓的源集,包含特定源代码和所需资源,每一个源集不是任意命名的,每一个源集对应一个BuildTypeProductFlavorBuildVariant,看官方的文档描述。

sourceSet

参考:https://developer.android.google.cn/studio/build/index.html#sourcesets

Android Studio在编译某个构建变体的时候,并不是单独的使用某个源集,而是merge不同的源集,比如有一个SkyCommonClientCustom的构建变体,并且定义了skyCommonClientCustom, custom, sky, commonClient这四个源集,那么在构建的时候就会合并上述的四个源集和默认的main源集的源代码和资源。

上面提到了merge合并资源,那么合并的优先级是怎样的呢?


SourceSetPority

需要补充一点,如果在ProductFlavor使用了flavorDimensions,比如:

flavorDimensions 'type', 'common'

sourceSets{
    sky{
       .....
    }
    
    commonClient{
       .....
    }
}

productFlavors{
    sky{
        dimension 'type'
        //直接在原有包名后面添加
        applicationIdSuffix ".sky"
    }

    commonClient{
        dimension 'common'
    }
}

那么源集sky的优先级高于commonClient,所以如果把flavorDimensions看做一个数组的话,最终的优先级是:

BuildVariant > BuidlType > flavorDimensions[0] > ... > flavorDimensions[x] > main > 内容库依赖项(aar等)

9.2 sourceSet的基本属性

sourceSet简单来说就是指定在编译指定了某些特定源代码和资源的集合,在Android中使用的是googleAndroidSourceSet

android闭包中的sourceSets就是由上面用户定义的一系列的AndroidSourceSet的集合。

先看AndroidSourceSet的DSL属性结构图:

AndroidSourceSet

从上面的图中我们可以看出,针对每个AndroidSourceSet可以配置不同的:

//跨进程通信声明文件(.aidl)
aidl

//assets文件下文件
assets

//.java文件目录
java

//.c, .cpp文件位置
jni 

//.so文件路径,注意该路径只需要指定到所包含平台的外层,不需要指定到具体的平台如`armeabi`,否则无法找到SO
jnilibs

//mainfest文件
manifest

//Android resource
res

//(java resource)
resource

//渲染脚本
rendersript

最终根据这些不同的资源集合生成不同的apk

以下四个属性为只读属性:

//sourceSet的名称,如custom
name 

//编译时的配置名称,如customCompile,与后面要讲的dependencies的配置项compile相对应。
compileConfigurationName

//如customApk,与后面要讲的dependencies的配置项apk相对应。
packageConfigurationName

//如customProvided,与后面要讲的dependencies的配置项provided相对应。 
providedConfigurationName

9.3 示例

sourceSets {
    main{
        jniLibs.srcDirs=['libs']
    }

    custom{
        //指定一个新的jnilibs为根目录下的jnilibs
        jniLibs.srcDirs=[rootProject.projectDir.absolutePath + '/jnilibs']

        //指定一个新的assets为根目录下的skyTestAssets目录
        assets.srcDirs = [rootProject.projectDir.absolutePath + '/assets/skyTestAssets']
    }
}

上面的例子中我们给custom这个源集指定了新的jniLibs和Assets目录,上面的源集也可以采用闭包的形式。

custom{
    
    jniLibs{
        //指定一个新的jnilibs为根目录下的jnilibs
        srcDirs=[rootProject.projectDir.absolutePath + '/jnilibs']
    }

    
    assets{
        //指定一个新的assets为根目录下的skyTestAssets目录
        srcDirs = [rootProject.projectDir.absolutePath + '/assets/skyTestAssets']
    }
}

两点注意:

  • srcDirsrcDirs的区别,当使用srcDir指定文件目录时,不允许将要合并的源集的同一目录下有一样名称的资源,提示重复资源异常,而srcDirs则会根据前面所说的优先级进行覆盖。

  • 如果我们在src/main的同级目录下,也建立一个如下的文件目录:

buildTypeCustonDir

当在源集中指定了asserts的目录时,custom/assets目录会直接失效。

9.4 未解决的问题

在上面的源集中,Android官方构建指南没有提及一点,就是如何过滤源集目录下的一些文件不编译或打包到最后的apk中,我使用如下方式,希望过滤掉src/main/assets/mainIngoreTest.txt文件不打包到最终的apk中。

sourceSets
{
    .....
    custom{
        //指定一个新的jnilibs为根目录下的jnilibs
        jniLibs.srcDirs=[rootProject.projectDir.absolutePath + '/jnilibs']

        //指定一个新的assets为根目录下的skyTestAssets目录
        assets.srcDirs = [rootProject.projectDir.absolutePath + '/assets/skyTestAssets']
        
        //意图过滤掉`src/main/assets/mainIngoreTest.txt`文件
        assets.exclude (project.projectDir.absolutePath + "\\src\\main\\assets\\mainIngoreTest.txt")
        
        //或者采用闭包
        assets.exclude{
            File f = it.file
            println f.absolutePath
            f.absolutePath.endsWith("mainIngoreTest.txt")
        }
        
    }
    .....
}

采用上述方式并不能成功过滤掉文件,并且上面的闭包中的代码也没有执行,目前还没有找到原因。

10 Dependencies(依赖内容库)

依赖内容库指的是不是当前工程的代码或资源,它们存在于其他的项目中,它们可以以源码库,jar,arr形式存在。

10.1 声明依赖项的三种方式

先看声明方式。

android {...}
...
dependencies {


    // 添加含有源码的模块依赖
    compile project(":mylibrary")

    // 远程二进制库依赖
    compile 'com.android.support:appcompat-v7:25.1.0'

    // 本地库(jar)依赖
    compile fileTree(dir: 'libs', include: ['*.jar'])
}

下面逐一介绍,以下来自官方文档。

模块依赖项

  • compile project(':mylibrary')行声明了一个名为mylibrary的本地Android库模块作为依赖项,这样的库可能是另外某个工程的一部分,此时是具有源代码的,注意依赖本地库模块时,必须在根目录下的settings.gradle文件中include它。

远程二进制依赖项

  • compile 'com.android.support:appcompat-v7:25.1.0' 行通过指定其 JCenter 远程仓库中的标识,当本地不存在该依赖库时,则自动从远程下载,默认存放在sdk/extras/目录下,当然我们也可以在 SDK 管理器下载和安装特定的依赖项。

本地二进制依赖项

  • 简单来说就是依赖已经打包好的jar库,compile fileTree(dir: 'libs', include: ['*.jar'])的意思就是依赖app/libs目录下的所有的以.jar结尾的文件。

10.2 配置依赖项

当我们希望对依赖项在编译和打包时做一些特殊处理的时候,通过使用不同的关键词,google给我们提供三种配置方式:

  • compile,最常见的配置项,编译时依赖,Gradle将此配置依赖项添加到类路径和最终的apk中,其实就是说在编译时和最终的apk中都需要。

  • apk,其指定的依赖项只在打包最终的apk的时候才需要,此配置只能和JAR二进制依赖项一起使用,而不能与其他库模块依赖项或 AAR 二进制依赖项一起使用。

  • provided,其指定的依赖项,此配置依赖项将添加到类路径中,只在编译的时候需要,不打包到最终的apk中(也就是说运行时无须该依赖项),比如我们编译时使用的SDK就属于这一类,同样的此配置只能和JAR二进制依赖项一起使用,而不能与其他库模块依赖项或 AAR 二进制依赖项一起使用。

示例:

dependencies { 

    .....

    // 依赖app/apklib下的jar文件,只在apk打包的时候依赖
    apk fileTree(dir: 'apklib', include: ['*.jar'])
    
    // 依赖app/rovidedlib下的jar文件,只在编译的时候依赖
    provided fileTree(dir: 'providedlib', include: ['*.jar'])
}

10.3 指定特定的构建变体的依赖项

在实际构建中,我们常常遇到有这样的需求,我们希望某些依赖项只有在某些特定构建变体编译时才被依赖,在Android Studio中我们可以指定依赖项在以下特定的构建类型下才依赖:

  • BuildTypes
  • BuildVariant
  • ProductFlavors

也就是说我们可以以上述任意一种方式指定特定的依赖项,我们知道每一个buildType, flavor都有一个与之名字相同的的sourceSet,所以我们要指定依赖项为某特定类型的方式为:

  • sourceSet.compileConfigurationName,如skyCompile
  • sourceSet.packageConfigurationName,如skyApk
  • sourceSet.providedConfigurationName,如skyProvided

以下是具体示例。

dependencies {

    // BuildTypes为AndroidTest,做单元测试时才编译
    androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
    })
    
    
    // BuildTypes为test的 依赖项junit
    testCompile 'junit:junit:4.12'

    // BuildTypes为debug的添加含有源码的模块依赖
    debugCompile project(":mylibrary")
    
    // flavor为sky的指定jar库,且不使用`okhttp-3.2.0.jar`库,使用skyCommon目录下的okhttp-3.3.1.jar
    skyCompile fileTree(include: ['*.jar'], dir: 'jar/sky', excludes: ['okhttp-3.2.0.jar'])
    
    // 构建变体依赖项指定
    skyCommonClientCustomCompile fileTree(include: ['*.jar'], dir: 'jar/skyCommonClientCustom')

    //以下是正常的依赖
    // 远程二进制库依赖
    compile 'com.android.support:appcompat-v7:25.1.0'

    // 本地库(jar)依赖
    compile fileTree(dir: 'libs', include: ['*.jar'])
    
    compile 'com.android.support:design:24.2.1'
}

注意,在上述使用xxxCompile时,有时会提示找不到对应的xxxCompile方法的错误:

Error:(180, 0) Could not find method skyCommonClientCustomCompile() for arguments [directory 'jar/skyCommonClientCustom'] on object of type org.gradle.api.internal.artifacts.dsl.dependencies.DefaultDependencyHandler.
<a href="openFile:E:\Source\GitHub\GradleBuildDemo\app\build.gradle">Open File</a>

解决办法为,我们在sourceSets闭包下,新建一个空的对应的soruceSet就可以了。

sourceSets{
    .....

    // 此处增加一个空的sourceSet,是为了解决在dependencies中使用
    // skyCommonClientCustomCompile 指定依赖项时提示找不到方法的错误
    skyCommonClientCustom{

    }

}

资源合并的规则同样适用与依赖合并,所以在指定特定依赖后,构建某个特定变体时(flavorType),其编译时最终的依赖项(不考虑provided)就变为:

flavorTypeCompile + typeCompile + flavorCompile + compile

指定依赖项的构建类型

我们可以直接指定依赖项的构建类型,这里的构建类型可以是BuildVariant, buildType, flavor

dependencies {
    ...
    // relase构建时指定依赖的`library`也是release
    releaseCompile project(path: ':library', configuration: 'release')
    
    // debug构建时指定依赖的`library`的构建也是`debug`
    debugCompile project(path: ':library', configuration: 'debug')
    ......
}

10.4 transitive, force, exclude的使用与依赖冲突解决

通过gradle命令查看依赖树,在模块所在的目录(app目录),执行gradle dependencies,执行结果如图(以androidTest为例)。

android_test_dependenice

transitive

transitive用于自动处理子依赖项。默认为true,gradle自动添加子依赖项,形成一个多层树形结构;设置为false,则需要手动添加每个依赖项。

  • 为所有的配置指定自动添加子依赖项为false
configurations.all {
   transitive = false
}
  • 为单独的某个依赖项指定字典添加子依赖项为false
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
       transitive = false 
    })

force

即强制设置某个模块的版本。

configurations.all {
   resolutionStrategy {
       force 'com.android.support.test:runner:0.2'
   }
}

以上设置之后所有对com.android.support.test:runner模块有依赖的其他库都被强制使用0.2版本。

exclude

排除依赖项中的某些子依赖项,这在解决依赖库版本冲突或者重复时特别有用,我们可以通过如下两种方式进行排除:

  • groupmaven项目的GroupId,GroupID是项目组织唯一的标识符,对于小型的项目,常常对应JAVA的包的结构,是main目录里java的目录结构,但是也可以很多个项目共用一个GroupID,如com.android.support下就有很多个子项目。

  • module, maven项目的ArtifactID,ArtifactID就是具体某个项目的唯一的标识符,实际常常对应项目的名称,就是项目根目录的名称,如support-annotations

groupmodule可以配合一起使用也可以单独使用。

  • 配合使用
//移除所有依赖项中,组织为`com.android.support`项目为`support-annotations`的子依赖项
configurations {
   all*.exclude group: 'com.android.support', module: 'support-annotations'
}

//移除单个依赖项中,组织为`com.android.support`项目为`support-annotations`的子依赖项
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
    exclude group: 'com.android.support', module: 'support-annotations'
})
  • 单独使用groupmodule
//移除所有依赖项中,组织为`com.android.support`的子依赖项
configurations {
   all*.exclude group: 'com.android.support'
}

//移除单个依赖项中,组织为`com.android.support`的子依赖项
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
    exclude group: 'com.android.support'
})

  • 单独使用module
//移除所有依赖项中名为`support-annotations`的子依赖项
configurations {
   all*.exclude module: 'support-annotations'
}

//移除单个依赖项中名为`support-annotations`的子依赖项
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
    exclude module: 'support-annotations'
})

依赖项的版本冲突

gradle在同一个配置下(例如androidTestCompile),某个模块的不同版本同时被依赖时,默认使用最新版,gradle同步时不会报错,例如:

dependencies {
   androidTestCompile('com.android.support.test:runner:0.4')
   androidTestCompile('com.android.support.test:rules:0.2')
   androidTestCompile('com.android.support.test.espresso:espresso-core:2.1')
}

上面espresso:espresso-core依赖runner不同与上面我们上面指定的版本的runner,此时gradle会自动同步最新版本。

对于不同的配置下,出现的依赖项不一样,gradle就会直接报错,这时我们就可以使用上面提到的force, exclude来解决。

10.5 参考

11 综合

11.1 过滤不需要生成的apk

当我们添加自定义的BuildType, flavor时,必然会组合出很多我们不需要的apk,google也给我们提供了解决方案。

核心就是使用variantFilter这个方法过滤满足我们特定条件的所有构建变体。

// 移除不需要打包的apk
variantFilter { variant ->
    String buildTypeName = variant.buildType.name
    String flavors0Name = variant.getFlavors().get(0).name

    //对于编译类型为`release 或者 custom` 并且 flavors0类型为`smith 或 gavin`的构建类型直接忽略,不用编译
    if((buildTypeName.equals('release') || buildTypeName.equals('custom'))
            && (flavors0Name.equals('smith') || flavors0Name.equals('gavin'))) {
        variant.setIgnore(true);
    }
}

11.2 apk名称修改

Android studio 构建生成的apk的默认名称为app-flavor[0]-...-flavor[n]-buildType.apk,google给我们提供了修改Apk名称的方法。

applicationVariants包含了所有可能构建变体的集合,我们使用闭包遍历所有的输出,修改我们想修改的apk的名称,以下是一个示例。

//修改生成的apk的名称,命名为demo-flavorsName-buildType-versionName.apk
applicationVariants.all { variant ->
    //遍历所有的输出文件
    variant.outputs.each { output ->
        File tempFile = output.outputFile
        //对于包含`commonClient` flavor的我们在名称中去掉它
        if (variant.productFlavors[1].name.contains("commonClient")) {

            output.outputFile = new File(tempFile.parent, tempFile.name.replace(tempFile.name,
                    "demo" + variant.productFlavors[0].name + "_" + variant.buildType.name + "_${variant.versionName}.apk"))
        } else {
            output.outputFile = new File(tempFile.parent, tempFile.name.replace(tempFile.name,
                    "demo" + variant.productFlavors[0].name + "_" + variant.productFlavors[1].name + "_" + variant.buildType.name + "_${variant.versionName}.apk"))
        }
    }
}

11.3 mainfest文件添加属性

当我们打多渠道包或需要给不同的构建变体加入的不同的属性时,此时我们就需要修改mainfest文件,gradle给我们提供了两种方式动态的修改mainfest文件。

在变体对应src目录下添加一个mainfest文件

在添加的mainfest文件中添加/修改配置项,此种方式与动态合并res/values/strings.xml的方式一致,其遵守的规则也和它们保持一致,这种方式不仅能添加属性,还能添加四大组件等等,具体可以参考:

Mainfest合并规则:https://developer.android.google.cn/studio/build/manifest-merge.html

使用APG中的manifestPlaceholders属性

manifestPlaceholdersbuild.gradle中以键值对的方式给mainfest中对应键设置相应的值的方式来实现,看如下示例,我在mainfest文件中添加以下需要动态设置的属性。

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        
        .....
        <!--渠道配置信息AppKey  -->
        <meta-data
            android:name="APP_KEY"
            android:value="${APP_KEY_VALUE}"/>

        <!-- 产品ID -->
        <meta-data
            android:name="APP_ID"
            android:value="${APP_ID_VALUE}"/>
    
        .....

   </application>

接下来我分别在defaultCofig, sky, gavin三个变体中从不同路径的配置文件config.xml中读取属性,这里我把解析xml文件放在根目录下自定义的utils.gradle文件中。


/**
 * 解析XML文件
 * */
def parseXml(String path) {
    println ("parseXml called, path = " + path)
    return new XmlParser().parse(path)
}


ext {
    parseXml       = this.&parseXml
}

然后在模块的build.gradle文件中引用它。

// 加载自定义的utils.gradle
apply from: rootProject.projectDir.getAbsolutePath() + File.separator + "utils.gradle"

最后在三个变体中解析相应的配置文件。

defaultConfig {
    
    ......

    def defaultConfig = parseXml("app/config/main/config.xml")
    manifestPlaceholders = [
            APP_KEY_VALUE : defaultConfig.appKey[0].text(),
            APP_ID_VALUE  : defaultConfig.id[0].text()
    ]
}

flavor{

    sky{
        ......
        def skyConfig = parseXml("app/config/sky/config.xml")
        manifestPlaceholders = [
            APP_KEY_VALUE : skyConfig.appKey[0].text(),
            APP_ID_VALUE  : skyConfig.id[0].text()
        ]
    }
    
    gavin{
        ......
        def gavinConfig = parseXml("app/config/gavin/config.xml")
        manifestPlaceholders = [
                APP_KEY_VALUE : gavinConfig.appKey[0].text(),
                APP_ID_VALUE  : gavinConfig.id[0].text()
        ]
        
    }
    
    ......
}

这样最后构建出来的apk使用的就是上面配置的不同的值。

11.4 APG动态生成的BuildConfig类的使用

使用BuildConfig

使用APG构建apk,会动态自动生成一个BuildConfig类,里面会包含当前构建变体的一些基本属性,如版本号等等,一下是一个默认下的示例(当前构建变体是skyCommonClientDebug)。

/**
 * Automatically generated file. DO NOT MODIFY
 */
package com.sky.gradlebuilddemo;

public final class BuildConfig {
  public static final boolean DEBUG = Boolean.parseBoolean("true");
  public static final String APPLICATION_ID = "com.inpor.fmcdevicedemon.sky";
  public static final String BUILD_TYPE = "debug";
  public static final String FLAVOR = "skyCommonClient";
  public static final int VERSION_CODE = 120;
  public static final String VERSION_NAME = "1.2.0";
  public static final String FLAVOR_type = "sky";
  public static final String FLAVOR_common = "commonClient";
}

一种最典型的用法,就是在源代码中判断当前是不是debug版本,然后做某些操作,如果是其他版本又做什么操作等等。

一个示例,在MainActivity中添加如下代码。

public class MainActivity extends AppCompatActivity {

    private static final String TAG = "MainActivity";
    
    .....

    private void checkBuildConfig(){
        if(BuildConfig.DEBUG){
            Log.i(TAG, "now this is debug build");
        }
    }
}

自定义BuildConfig属性

APG给我们提供了自定义BuildConfig属性的方法buildConfigField,注意,添加Field时,最好在defaultConfig中给要添加的Field设置一个默认值,否则当编译其他没有设置该Field的变体时,会编译报错,我们可以在buildTypes, flavors中复写它。

在下面的示例中我添加一个字段。

defaultConfig {
    
    ......
    buildConfigField 'int', 'ID', '0'
}

flavor{

    sky{
        ......
        buildConfigField 'int', 'ID', '1'
    }
    
    ......
}

最终生成的BuildConfig是这样的。

/**
 * Automatically generated file. DO NOT MODIFY
 */
package com.sky.gradlebuilddemo;

public final class BuildConfig {
  public static final boolean DEBUG = Boolean.parseBoolean("true");
  public static final String APPLICATION_ID = "com.inpor.fmcdevicedemon.sky";
  public static final String BUILD_TYPE = "debug";
  public static final String FLAVOR = "skyCommonClient";
  public static final int VERSION_CODE = 120;
  public static final String VERSION_NAME = "1.2.0";
  public static final String FLAVOR_type = "sky";
  public static final String FLAVOR_common = "commonClient";
  // Fields from product flavor: sky
  public static final int ID = 1;
  // Fields from default config.
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容