Android热修复工具Tinker集成

Tinker介绍

Tinker是微信团队开源的Android热修复工具,支持dex, library和resources的热更新。关于Tinker的基本的接入方法、Api和原理等,在官方wiki中有非常详细的介绍。我这里重点描述一下基于我们项目的接入流程(客户端和后台),使用姿势和遇到的问题,以及如何在Jenkins上构建补丁包。

Tinker接入

Tinker是目前热修复方案中稳定性和兼容性最好的,毕竟源于微信团队嘛!但也正是为了提高稳定性和兼容性,Tinker在接入成本上做了妥协,它不像以往的Andfix那样可以一键接入,必须改造自己的Application,详细可参考自定义Application类。其实改造的过程也并不复杂,只是多了一点学习成本。
但使用对Tinker进行再次封装的第三方平台的SDK还是可以实现一键接入的,比如TinkerPatch平台Bugly热更新功能,但这种方式对Application进行了反射,是有风险的:

TinkerPatch 平台通过自动反射 Application,可以实现无缝接入。事实上,对于反射失败的情况,我们会自动回退到代理 Application 生命周期模式,防止因为反射失败而造成应用无法启动的问题。
通过线上统计,大约有 1/1W的反射失败率。我们更加推荐大家使用 Tinker 的方式改造自身的 Application, 使兼容性高。

而且我们需要自己搭建后台来管理补丁包,所以不会使用第三方SDK,而是自己封装了一套SDK,其实就是将Tinker的调用API和与后台接口的通信功能进行了整合而已,封装方式和后台搭建也是基于github上的一个开源项目的:https://github.com/baidao/tinker-manager

我们的客户端SDK已经放在了公司内部的Maven仓库中:compile 'com.****.tinkerutils:utils:${version}'
我们已经搭建好的补丁管理平台测试地址是:http://172.22.34.201/hotfix-console/

开始接入
gradle是Tinker推荐的接入方式,如果要使用命令行接入请参考这里
第一步,引入Tinker插件和依赖
添加tinker-gradle-plugin到工程根目录下的build.gradle的dependencies中:

buildscript {
    dependencies {
        classpath ('com.tencent.tinker:tinker-patch-gradle-plugin:1.7.7')
    }
}

然后在你的主module中的build.gradle文件里apply插件:

apply plugin: 'com.tencent.tinker.patch'

注意:这里的主module是指应用的启动Application所在的module,即含有apply plugin: 'com.android.application'这句话的module,对应我们项目就是MyMoney,否则Tinker会抛出Exception

然后加入Tinker的lib依赖:

dependencies {
    //optional, help to generate the final application
    provided('com.tencent.tinker:tinker-android-anno:1.7.7')
    //tinker's main Android lib
    compile('com.tencent.tinker:tinker-android-lib:1.7.7') 
}

但因为我们使用自己的SDK,SDK中已经有了Tinker lib的依赖,所以,我们加入SDK的依赖即可:

dependencies {
    //optional, help to generate the final application
    provided('com.tencent.tinker:tinker-android-anno:1.7.7')
    //our tinker SDK
    compile 'com.****.tinkerutils:utils:${version}'
}

第二步,改造项目原有的Application
将我们现有的AppApplication直接继承Tinker提供的DefaultApplicationLike类,参考自定义Application类,这样我们的AppApplication就成了真实的Application(RealApplication,自定义或者通过注解自动生成)的代理类,这样做就是为了将RealApplication隔离起来,防止误修改,如此一来,在RealApplication中所做的所有初始化工作也就相当于转移到了代理类中,间接实现了Application可修改进行热修复的目的。
比如我们原来的AppApplication如下:

public class AppApplication extends Application {

    private static final String TAG = "AppApplication";

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        try {
            MultiDex.install(base);
            context = this;
        }catch (Exception e){
            DebugUtil.exception(TAG,e);
        }
    }

    @Override
    public void onCreate() {
        super.onCreate();
        // 全局初始化代码
        ... ...
    }

    // 复写了Application的方法
    @Override
    public Resources getResources() {
        Resources res = super.getResources();
        if(res.getConfiguration().fontScale != 1){
            Configuration newConfig = res.getConfiguration();
            newConfig.fontScale = 1;
            res.updateConfiguration(newConfig, res.getDisplayMetrics());
        }
        return res;
    }

    @Override
    public void startActivities(Intent[] intents) {
        // do some option
        ... ...
        super.startActivities(intents);
    }
}

那我们改造后应该是这样:

@DefaultLifeCycle(application = "${yourpackage}.RealApplication",
        flags = ShareConstants.TINKER_ENABLE_ALL)
public class AppApplication extends DefaultApplicationLike {

    private static final String TAG = "AppApplication";

    public AppApplication(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag, long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent) {
        super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent);
    }

    @Override
    public void onBaseContextAttached(Context base) {
        super.onBaseContextAttached(base);
        try {
            //you must install multiDex whatever tinker is installed!
            MultiDex.install(base);
            //此处通过getApplication()拿到的其实就是RealApplication
            context = getApplication();
            //install tinker
            TinkerUtils.installTinker(getApplication(), this);
        }catch (Exception e){
            DebugUtil.exception(TAG,e);
        }
    }

    @Override
    public void onCreate() {
        super.onCreate();
        // 设置tinker参数并向后台请求补丁包
        TinkerUtils.setUpTinker(context);
        // 全局初始化代码
        ... ...
    }

    @Override
    public Resources getResources(Resources res) {
        if(res.getConfiguration().fontScale != 1){
            Configuration newConfig = res.getConfiguration();
            newConfig.fontScale = 1;
            res.updateConfiguration(newConfig, res.getDisplayMetrics());
        }
        return res;
    }
    public Resources getResources() {
        return getApplication().getResources();
    }
}

上面是通过注解的方式来自动生成RealApplication,如果使用自定义的方式,则直接新建RealApplication类继承TinkerApplication并创建对应构造方法即可,不用注解,也不用引入注解依赖。

public class RealApplication extends TinkerApplication {
    public RealApplication() {
      super(
        //tinkerFlags, tinker支持的类型,dex,library,还是全部都支持!
        ShareConstants.TINKER_ENABLE_ALL,
        //ApplicationLike的实现类,只能传递字符串 
        "tinker.sample.android.app.SampleApplicationLike",
        //Tinker的加载器,一般来说用默认的即可
        "com.tencent.tinker.loader.TinkerLoader",
        //tinkerLoadVerifyFlag, 运行加载时是否校验dex,lib与res的Md5
        false);
    }  
}

官方提示:除了构造方法之外,你最好不要引入其他的类,这将导致它们无法通过补丁修改。

注意:改造完成后要用RealApplication替换掉AndroidManifest.xml中原来的AppApplication:

    <application
        android:name=".RealApplication"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme">
        ... ...

另外因为我们的SDK中有自定义AbstractResultService类,即TinkerResultService,所以也需要在清单文件中加上它,否则补丁合成会出问题

     <service
            android:name="com.feidee.tinkerutils.TinkerResultService"
            android:exported="false"/>

参考TinkerApplication源码可以知道为什么如此修改Application:

    ... ...
    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        Thread.setDefaultUncaughtExceptionHandler(new TinkerUncaughtHandler(this));
        onBaseContextAttached(base);
    }

    private void onBaseContextAttached(Context base) {
        applicationStartElapsedTime = SystemClock.elapsedRealtime();
        applicationStartMillisTime = System.currentTimeMillis();
        loadTinker();
        ensureDelegate();
        //此处的applicationLike现在就是我们的AppApplication类
        applicationLike.onBaseContextAttached(base);
        //reset save mode
        if (useSafeMode) {
            String processName = ShareTinkerInternals.getProcessName(this);
            String preferName = ShareConstants.TINKER_OWN_PREFERENCE_CONFIG + processName;
            SharedPreferences sp = getSharedPreferences(preferName, Context.MODE_PRIVATE);
            sp.edit().putInt(ShareConstants.TINKER_SAFE_MODE_COUNT, 0).commit();
        }
    }
    ... ...

    @Override
    public void onCreate() {
        super.onCreate();
        ensureDelegate();
        //此处的applicationLike现在就是我们的AppApplication类
        applicationLike.onCreate();
    }
    ... ...

    @Override
    public Resources getResources() {
        Resources resources = super.getResources();
        if (applicationLike != null) {
            //此处的applicationLike现在就是我们的AppApplication类
            return applicationLike.getResources(resources);
        }
        return resources;
    }
    ... ...

改造原来AppApplication复写的startActivities方法时,我发现DefaultApplicationLike类中并没有类似getResources的代理方法,所以我只有将这个复写放到了RealApplication中。

第三步,增加Tinker的gradle配置
Tinker的gradle参数配置很灵活,具体的参数设置事例可参考官方sample中的app/build.gradle,为了使gradle文件不至于太混杂,我将Tinker相关的配置单独抽取出来放在新建的tinker_support.gradle文件中,然后在MyMoney/build.gradle文件中加入下面一行即可:

// tinker config
apply from: 'tinker_support.gradle'

为了更方便的构建,主要修改了以下配置参数:

//基准apk包的备份路径,这里仅作备份用,每次构建apk时会将生成的apk文件、mapping和R文件自动拷贝一份到这个目录下去
def bakPath = file("${buildDir}/bakApk/")
//构建补丁时获取基准apk包的文件名
def getTinkerBaseApkFileName(def defaultName) {
    return hasProperty("TINKER_BASE_APK_NAME") ? TINKER_BASE_APK_NAME : defaultName
}
/**
 * you can use assembleRelease to build you base apk
 * use tinkerPatchRelease -POLD_APK=  -PAPPLY_MAPPING=  -PAPPLY_RESOURCE= to build patch
 * add apk from the build/bakApk
 */
ext {
    //for some reason, you may want to ignore tinkerBuild, such as instant run debug build?
    tinkerEnabled = isRelease();
    tinkerBaseApkFileName = getTinkerBaseApkFileName("Mymoney_base.apk")// todo 构建时需要在此配置基准包的filename
    //proguard mapping file to build patch apk
    tinkerMappingFileName = tinkerBaseApkFileName.substring(0, tinkerBaseApkFileName.length() - 4) + "-mapping.txt"
    //resource R.txt to build patch apk, must input if there is resource changed
    tinkerSymbolFileName = tinkerBaseApkFileName.substring(0, tinkerBaseApkFileName.length() - 4) + "-R.txt"
}
/**
 * mapping 文件 路径取得是 rootDir/tinker/mapping/
 * @return
 */
def getMappingFilePath() {
//    String baseMappingPath = project.projectDir.toString() + "/document/mapping/"
    String baseMappingPath = "${rootDir}/tinker/mapping/"
    String tailPath = ext.tinkerMappingFileName
    String middlePath = ""
    if (hasProperty("channelCode")) {
        middlePath = channelCode + "/"
    }
    return baseMappingPath + middlePath + tailPath
}
/**
 * R 文件 路径取得是 rootDir/tinker/symbol/
 */
def getSymbolFilePath() {
    String baseSymbolPath = "${rootDir}/tinker/symbol/"
    String tailSymbolPath = ext.tinkerSymbolFileName
    String middleSymbolPath = ""
    if (hasProperty("channelCode")) {
        middleSymbolPath = channelCode + "/"
    }
    return baseSymbolPath + middleSymbolPath + tailSymbolPath
}
/**
 * 基准apk文件  rootDir/tinker/apk/
 * @return
 */
def getBaseApkFilePath() {
    String baseApkPath = "${rootDir}/tinker/apk/"
    String tailApkPath = ext.tinkerBaseApkFileName
    String middleApkPath = ""
    if (hasProperty("channelCode")) {
        middleApkPath = channelCode + "/"
    }
    return baseApkPath + middleApkPath + tailApkPath
}

// tinkerId是唯一标识,这里默认指定为apk的版本号
def getTinkerIdValue() {
    return hasProperty("TINKER_ID") ? TINKER_ID : ext.apkVersionName
... ...
//如果在本地目录下找不到基准apk,就去我们放基准包的地方去下载
def downloadBaseApkFile(def address, def savePath) {
    new File(savePath).withOutputStream { out ->
        out << new URL(address).openStream()
    }
}

以上更改把基准包的获取路径改到了根目录下的tinker目录中,把tinkerEnabled的值付给isRelease()函数,这样在实际应用发布版本时,我们手动在创建一个tinker目录及其相应文件夹(只需创建一次即可),然后将我们构建好的apk、相应的mapping和R文件(需改名为${tinkerBaseApkFileName}-mapping.txt和${tinkerBaseApkFileName}-R.txt),这样做的好处时基准包可以放在本地不被clean掉,方便在本地手动构建时进行管理。

第四步,安装和初始化Tinker
上面改造后的AppApplication中有两行代码用于Tinker的安装和初始化:

//install tinker 
TinkerUtils.installTinker(getApplication(), this);
// 设置tinker参数并向后台请求补丁包
TinkerUtils.setUpTinker(context);

TinkerUtils代码如下:

public class TinkerUtils {
    private static final String TAG = "Tinker";
    public static void installTinker(Context context, ApplicationLike applicationLike) {
        // 安装tinker
        SampleTinkerManager.initCurrentChannelValue(ChannelUtil.getChannel());
        SampleTinkerManager.setTinkerApplicationLike(applicationLike);
        SampleTinkerManager.initFastCrashProtect();
        //should set before com.dx168.patchsdk.sample.tinker is installed
        SampleTinkerManager.setUpgradeRetryEnable(true);
        //installTinker after load multiDex
        //or you can put com.tencent.com.dx168.patchsdk.sample.tinker.** to main dex
        SampleTinkerManager.installTinker(applicationLike);
        Tinker.with(context);
        //使用Hack的方式,如果补丁中有so库 那么直接加载补丁中的armeabi下的so库(将tinker library中的armeabi注册到系统的library path中。)
        TinkerLoadLibrary.installNavitveLibraryABI(context, "armeabi");
    }
    public static void setUpTinker(Context context) {
        if (ChannelUtil.isGoogleVersion()) {
            return;
        }
        //在补丁管理后台注册的id和key,参数值配置在gradle文件中
        String appId = BuildConfig.TINKER_APP_ID;
        String appSecret = BuildConfig.TINKER_APP_SECRET;
        String tinkerUrl = BuildConfig.TINKER_PATCH_URL;
        PatchManager.getInstance().init(context, tinkerUrl, appId, appSecret, new ActualPatchManager() {
            @Override
            public void cleanPatch(Context context) {
                TinkerInstaller.cleanPatch(context);
                DebugUtil.debug(TAG, "local patch sdk >>>>> cleanPatch");
            }
            @Override
            public void applyPatch(Context context, String patchPath) {
                TinkerInstaller.onReceiveUpgradePatch(context, patchPath);
                DebugUtil.debug(TAG, "local patch sdk >>>>> applyPatch: " + patchPath);
            }
        });
        PatchManager.getInstance().setTag(ChannelUtil.getChannel());//可用于灰度发布
        PatchManager.getInstance().setChannel(ChannelUtil.getChannel());
        PatchManager.getInstance().queryAndApplyPatch(new PatchListener() {
            @Override
            public void onQuerySuccess(String response) {
                DebugUtil.debug(TAG, "local patch sdk >>>>>  onQuerySuccess response={ignore in log}");
            }
            @Override
            public void onQueryFailure(Throwable e) {
                DebugUtil.debug(TAG, "local patch sdk >>>>>  onQueryFailure e=" + Log.getStackTraceString(e));
            }
            @Override
            public void onDownloadSuccess(String path) {
                DebugUtil.debug(TAG, "local patch sdk >>>>>  onDownloadSuccess path=" + path);
            }
            @Override
            public void onDownloadFailure(Throwable e) {
                DebugUtil.debug(TAG, "local patch sdk >>>>>  onDownloadFailure e=" + Log.getStackTraceString(e));
            }
            @Override
            public void onApplySuccess() {
                DebugUtil.debug(TAG, "local patch sdk >>>>>  onApplySuccess");
            }
            @Override
            public void onApplyFailure(String msg) {
                DebugUtil.debug(TAG, "local patch sdk >>>>> onApplyFailure msg=" + msg);
            }
            @Override
            public void onCompleted() {
                DebugUtil.debug(TAG, "local patch sdk >>>>> onCompleted");
            }
        });
    }
}

构建补丁包

如上个步骤所说,构建好基准包后,将apk、mapping和R文件改好名字后放在tinker相应目录中,就可以开始构建补丁包了。打包方式:

直接使用task:tinkerPatchVariantName(例如tinkerPatchDebug、tinkerPatchRelease)即可自动根据Variant选择相应的编译类型,同时它还贴心的为我们完成以下几个操作:
1.将TINKER_ID自动插入AndroidManifest的meta项,输出路径为build/intermediates/tinker_intermediates/AndroidManifest.xml;
2.如果minifyEnabled为true,将自动将Tinker的proguard规则添加到proguardFiles中,输出路径为build/intermediates/tinker_intermediates/tinker_proguard.pro,这里你不需要将它们拷贝到自己的proguard配置文件中;
3.如果multiDexEnabled为true,将自动生成Tinker需要放在主dex的keep规则。在tinker 1.7.6版本之前,你需要手动将生成规则拷贝到自己的multiDexKeepProguard文件中。例如Sample中的multiDexKeepProguard file("keep_in_main_dex.txt")。在1.7.6版本之后,这里会通过脚本自动处理,无须手动填写。
4.把dexOptions的jumboMode打开。

我们构建Release包时,直接执行下面命令即可:

./gradlew tinkerPatchRelease

构建很快,输出目录为build/outputs/tinkerPatch/release,会产生两个带签名的apk格式的补丁patch_signed.apkpatch_signed_7zip.apk,构建log会提示我们哪个补丁更小并建议我们使用小的。更改代码和资源文件造成的改动量会影响补丁包的大小,只改一行代码的情况下,补丁包大约为4k。

测试

测试主要从以下几个方面进行:

  • 集成Tinker后,打包测试apk是否有可能存在的bug
  • 测试补丁下发流程及合成(补丁拉取时机是每次app进程重新启动时,拉取后会自动合成,合成后在锁屏或者app正好处于后台的情况下会自动杀掉app进程,补丁在进程重启后生效。如合成失败,会自动重试一次)
  • 测试包含不同类型修改的补丁(Tinker目前版本不支持清单文件的修改)
  • 修改Application(此处即指改造后的继承DefaultApplicationLike的类)
  • 修改其他代码
  • 修改资源文件
  • 测试补丁是否对渠道信息有影响
  • 测试对同一个基准apk下发多个补丁的情况(目前的策略后台会根据补丁上传的时间自动修改补丁的版本号,当高版本的补丁被下发时,已合成的补丁会自动被清除,再尝试合成新补丁)
  • app版本升级(在升级版本时我们也无须手动去清除补丁,框架已经为我们做了这件事情)

在后台创建好app,拿到对应的key配置到项目中,并创建对应基准apk的版本,上传对应版本的补丁包(后台会自动改名,所以下发的补丁包不会包含.apk的后缀名),选择是否灰度等,即可下发补丁。

补丁管理后台

Debug打印日志可以看到补丁的拉取和合成过程


补丁下载及合成过程

Jenkins构建支持

因为构建补丁包时,有三个变量,即基准apk、mapping和R文件,所以我们可以使用Jenkins提供的参数化构建。

构建命令如下,配置TINKER_BASE_APK_NAME为Mymoney_base.apk

修改构建的输出目录为:

MyMoney/build/outputs/tinkerPatch/release/patch_signed_7zip.apk,MyMoney/build/outputs/tinkerPatch/release/patch_signed.apk

在开始构建之前,我们需要上传相应文件来设置我们添加的三个文件参数,因为Jenkins会将我们设置好的文件参数指向我们上传的文件,而参数名称已经根据TINKER_BASE_APK_NAME写死而且符合规范,所以我们每次构建都无需对项目配置和Jenkins配置做任何修改,只需要上传对应基准文件即可(也无需修改文件名了)。


构建完成后就可以生成相应的补丁包:

遇到的问题

  1. 在Debug构建测试Tinker时,会出现不能断点调试的情况,这是因为我在测试时Debug模式将minifyEnabled设置为了true,所以无法在断点时识别代码。

  2. 在Release模式时,将tinkerEnabled设置为false,会报找不到Application的错误:



    原因也是开启了混淆,不过官方的demo也一样有这个问题。鉴于在Release的情况下,似乎不会将Tinker关闭,可忽略这个问题。

  3. 提示有png被修改,但是其实没改过。wiki中有提到这个问题,除了将cruncherEnabled关闭外,可能的原因是使用Run的方式构建了apk。

  4. 集成Tinker后第一次启动app崩溃,并且不打印任何错误堆栈。原来以为是分包的问题,经过多次测试,发现应该是在Tinker安装之前进行了多余的操作,另外Tinker的依赖最好放在启动Application所在的module中,因为Tinker的安装和构建都依赖Application。如果将其依赖放在其他lib库所在的module中,可能引起未知crash。

其他可能遇到的问题参考wiki:常见问题

扩展

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

推荐阅读更多精彩内容