阿里热修复Sophix集成与体验

前言

热更新很早就想体验一下了,最近工作不是很忙,腾出来一些时间体验了一下,感觉还是挺爽的。

开发环境:

  • Android Studio 3.5
  • com.aliyun.ams:alicloud-android-hotfix:3.2.8

创建项目

为了贴近实际的开发场景,在项目中引入了自己创建的基础库框架:

项目采用了组件化,项目结构如下:

  • app:壳项目,包含了 MainActivity 和 Application。
  • common:通用组件,包含通用依赖库声明,还有基类界面和组件功能接口。
  • launch:启动组件,包含一个 SplashActivity。
  • home:首页组件,包含一个HomeFragment。
  • user:用户组件,包含一个 UserFragment 和 一个 WebActivity,WebActivity里面是空的,只是声明了一个类,继承自 Activity,没有布局文件,只是在 AndroidManifest.xml 中配置了该类。

集成Sophix

1. 创建产品及应用

先创建产品及应用,可以参考阿里云官方文档:创建产品及应用

2. 集成SDK

2.1 添加依赖

在项目的 build.gradle 添加:

repositories {
    maven {
        url "http://maven.aliyun.com/nexus/content/repositories/releases"
    }
}

在 app 的 build.gradle 中添加:

dependencies {
    // 阿里热修复
    implementation 'com.aliyun.ams:alicloud-android-hotfix:3.2.8'
}

2.2 声明权限

AndroidManifest.xml 中声明 Sophix 需要的权限:

<! -- 网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<! -- 外部存储读权限,调试工具加载本地补丁需要 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

READ_EXTERNAL_STORAGE权限属于Dangerous Permissions,仅调试工具获取外部补丁需要,不影响线上发布的补丁加载,调试时请自行做好android6.0以上的运行时权限获取。

2.3 配置账号信息

AndroidManifest.xml 中间的 application 节点下添加如下配置:

<meta-data
    android:name="com.taobao.android.hotfix.IDSECRET"
    android:value="App ID" />
<meta-data
    android:name="com.taobao.android.hotfix.APPSECRET"
    android:value="App Secret" />
<meta-data
    android:name="com.taobao.android.hotfix.RSASECRET"
    android:value="RSA密钥" />

也可以不在这里配置账号信息,因为 App ID/App Secret 将被用于计量计费,处于安全考虑,官方建议在代码中使用 setSecretMetaData 这个方法设置账号信息,这个放在后面讲。

2.4 配置混淆

# 基线包使用,生成mapping.txt
-printmapping mapping.txt

# hotfix
-keep class com.taobao.sophix.**{*;}
-keep class com.ta.utdid2.device.**{*;}
-dontwarn com.alibaba.sdk.android.utils.**

# 防止inline
-dontoptimize

2.5 不使用R8

在项目的 gradle.properties 文件中添加如下配置:

# 热更新混淆需要使用-applymapping mapping.txt,而R8对这个支持的不是很好,所以暂不使用R8
android.enableR8=false

一般情况下,正式环境的代码是混淆了的,为了让热更新工作正常,对于补丁包,在混淆的时候,需要使用最初包(官方称为基线包)生成的 mapping 文件,这样补丁包中的混淆规则会和最初包保持一致。

为了达到上述的效果,需要使用-applymapping mapping.txt,在 Android Studio 3.5 上,R8 已经是默认的混淆工具,但是 R8 对这条指令支持的不是很好,我在尝试的时候,一直打包失败,在询问了官方技术支持后,选择不使用 R8。

2.6 初始化SDK

2.6.1 新建SophixStubApplication
import android.content.Context;

import androidx.annotation.Keep;
import androidx.multidex.MultiDex;

import com.taobao.sophix.SophixApplication;
import com.taobao.sophix.SophixEntry;
import com.taobao.sophix.SophixManager;

public class SophixStubApplication extends SophixApplication {
    // Sophix账号信息,如果在AndroidManifest.xml配置了,这里就不需要配置了,
    // 如果在代码中和AndroidManifest.xml同时配置了账号信息,以代码中的为准。
    private static final String APP_ID = "";
    private static final String APP_SECRET = "";
    private static final String RSA_SECRET = "";
    // 用户自定义aes秘钥, 会对补丁包采用对称加密。
    // 这个参数值必须是16位数字或字母的组合,是和补丁工具设置里面AES Key保持完全一致, 
    // 补丁才能正确被解密进而加载。此时平台无感知这个秘钥, 
    // 所以不用担心阿里云移动平台会利用你们的补丁做一些非法的事情。
    private static final String AES_KEY = "12djahdaufdaldha";

    private static OnPatchLoadStatusListener onPatchLoadStatusListener = null;

    // 此处SophixEntry应指定真正的Application,并且保证RealApplicationStub类名不被混淆。
    @Keep
    @SophixEntry(Application.class)
    class RealApplicationStub {

    }

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);

        // 解决方法数超过65536限制,这个应在initSophix之前调用
        // 原来的Application里面去除MultiDex,避免重复调用导致问题。
        MultiDex.install(this);

        initSophix();
    }

    public static void setOnPatchLoadStatusListener(OnPatchLoadStatusListener listener) {
        onPatchLoadStatusListener = listener;
    }

    private void initSophix() {
        String appVersion = "0.0.0";
        try {
            appVersion = this.getPackageManager()
                    .getPackageInfo(this.getPackageName(), 0)
                    .versionName;
        } catch (Exception e) {
            e.printStackTrace();
        }

        SophixManager instance = SophixManager.getInstance();
        instance.setContext(this)
                .setAppVersion(appVersion)
                // 设置账号信息
                .setSecretMetaData(APP_ID, APP_SECRET, RSA_SECRET)
                // 设置自定义aes秘钥
                .setAesKey(AES_KEY)
                .setEnableDebug(false) 
                .setPatchLoadStatusStub((mode, code, info, handlePatchVersion) -> {
                    if (onPatchLoadStatusListener != null) {
                        onPatchLoadStatusListener.onLoad(mode, code, info, handlePatchVersion);
                    }
                })
                .initialize();
    }

    public interface OnPatchLoadStatusListener {
        void onLoad(int mode, int code, String info, int handlePatchVersion);
    }
}
2.6.2 配置SophixStubApplication

修改 AndroidManifest.xml 文件,移除原本的 Application,设置为 SophixStubApplication:

<application
        android:name=".application.SophixStubApplication" 
        .../>
2.6.3 注意事项
2.6.3.1 使用Java编写SophixStubApplication

在实际项目中,我基本上都是使用 Kotlin 开发,对于 SophixStubApplication 应该使用 Java 编写,参考自Hotfix补丁工具报错排查步骤

reasons-for-SophixStubApplication-coding-by-java.jpg
2.6.3.2 不要在SophixStubApplication写业务逻辑

SophixStubApplication 是 Sophix 入口类,专门用于初始化 Sophix,不应包含任何业务逻辑。

此类必须继承自 SophixApplication,onCreate 方法不需要实现。

此类不应与项目中的其他类有任何互相调用的逻辑,必须完全做到隔离。

注意原先 Application里不需要再重复初始化 Sophix,并且需要避免混淆原先 Application 类。

2.7 添加tag(可选)

可以通过 setTags接口 [v3.2.7新增],设置端上拉取补丁包时的标签,可以支持条件更为丰富的灰度发布,以下为简单示例:

List<String> tags = new ArrayList<>();
tags.add("test");
//此处调用在queryAndLoadNewPatch()方法前
SophixManager.getInstance().setTags(tags);
List<String> tags = new ArrayList<>();
tags.add("production");
//此处调用在queryAndLoadNewPatch()方法前
SophixManager.getInstance().setTags(tags);
gray-release-tag.jpg

如上,设置不同的tags,同一版本号下,可以打两个或者多个基线包,线上发布时用production的基线包,测试环境用test的基线包,这样就可以测试同一版本号下的同一个补丁了,两个环境互不影响。tags可以add多个,结构为前后非空字符串即可。生成补丁时,用同样tags的基线包和修复包。

2.8 处理补丁加载回调

在 SophixStubApplication 中,并没有写补丁加载回调处理逻辑,而是创建了一个接口,通过观察者模式的方式把处理逻辑放在外面处理。这样的目的是,SophixManager 初始化后热更新才开始工作,写在初始化之前的代码热更新是不能作用到的。

在本项目中,把处理逻辑放在了实际的 Application 中:

class Application : Application() {

    override fun onCreate() {
        super.onCreate()

        SophixStubApplication.setOnPatchLoadStatusListener { mode, code, info, handlePatchVersion ->
            when (code) {
                PatchStatus.CODE_LOAD_SUCCESS -> {
                    LogUtil.i("sophix load patch success!")
                }
                PatchStatus.CODE_LOAD_RELAUNCH -> {
                    // 补丁加载成功,需要重启App生效
                    // 我这里的逻辑是杀死App,100毫秒后重启App
                    LogUtil.i("sophix preload patch success. restart app to make effect.")
                    RestartAppTool.restart(this, 100, packageName)
                    SophixManager.getInstance().killProcessSafely()
                }
                else -> {
                    LogUtil.i("sophix load error, code is $code")
                }
            }
        }
        
        ...
    }
}

这里有一点需要注意,不可以直接调用 Process.killProcess(Process.myPid()) 来杀进程,这样会扰乱 Sophix 的内部状态。因此如果需要杀死进程,建议调用 SophixManager.getInstance().killProcessSafely() 杀死进程,该方法会在内部做一些适当处理后才杀死本进程。

2.9 增加检查补丁更新逻辑

阿里 Sophix 的收费方式是按调用 queryAndLoadNewPatch 方法的次数收费,每月有一定的免费阈值:

  • 月活设备(MAU): 5万。每个账号,每月5万台设备免费。
  • 日均查询次数:20次。每个账号下的每个应用平均到每台设备,一天免费查补丁询20次。
  • 补丁分发:完全不做次数限制,不额外收取流量费。

请谨慎调用 queryAndLoadNewPatch

因为是 Demo,我这里的处理逻辑是很简单,首页有一个按钮,点击一下就调用一次 queryAndLoadNewPatch

override fun initListener() {
    super.initListener()
  
    btnCheckUpdate.onClick {
        // 检查更新
        LogUtil.i("检查更新")
        SophixManager.getInstance().queryAndLoadNewPatch()
    }
}

打最初包(基线包)

项目效果:

1. 备份最初包

app/build/outputs/apk/release 下的正式包备份,重命名为 app-release-0.apk

2. 移动maping.txt

app/build/outputs/mapping/release 路径下的 maping.txt 移动到 /app 路径下,

3. 修改混淆文件

将原来的混淆文件:

# 基线包使用,生成mapping.txt
-printmapping mapping.txt

# hotfix
-keep class com.taobao.sophix.**{*;}
-keep class com.ta.utdid2.device.**{*;}
-dontwarn com.alibaba.sdk.android.utils.**

# 防止inline
-dontoptimize

调整为:

# 修复后的项目使用,保证混淆结果一致
-applymapping mapping.txt

# hotfix
-keep class com.taobao.sophix.**{*;}
-keep class com.ta.utdid2.device.**{*;}
-dontwarn com.alibaba.sdk.android.utils.**

# 防止inline
-dontoptimize

这个改动是为了接下来打热修复补丁,如果接下来要发新版本(版本号需要变动),则还是使用 -printmapping mapping.txt

第一次热更新-修改资源

1. 更新代码

  1. 更换Splash的背景图片,不修改图片的名字,注意:SplashActivity没有设置布局文件,而是通过设置主题的方式设置的背景图片:

    <activity
        android:name=".SplashActivity"
        android:configChanges="orientation|keyboardHidden|screenSize"
        android:screenOrientation="portrait"
        android:theme="@style/SplashTheme" >
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
    </activity>
    
    <style name="SplashTheme" parent="AppTheme">
            <item name="android:windowNoTitle">true</item>
            <item name="android:windowFullscreen">true</item>
            <item name="android:windowContentOverlay">@null</item>
            <!-- 启动页背景 -->
            <item name="android:windowBackground">@drawable/launch_splash_1</item>
        </style>
    
  2. 替换首页第一张图片,不修改图片的名字,注意该图片是放在 drawable 文件下。

    替换首页第二张图片,不修改图片的名字,注意该图片是放在 assets 文件下。

    在布局文件中修改按钮文本的颜色。

    在字符串资源文件中修改按钮的文本。

  3. 替换我的第一张图片,修改图片的名字,注意该图片是放在 drawable 文件下。

    替换我的第二张图片,修改图片的名字,注意该图片是放在 assets 文件下。

    在布局文件中修改按钮文本的颜色。

    在字符串资源文件中修改按钮的文本。

    替换我的跳转至百度按钮点击事件中的toast文案。

2. 生成补丁

2.1 生成新包

运行打包命令,生成新包,重命名为 app-release-1.apk

2.2 下载打补丁工具

如果没有打补丁工具 SophixPatchTool,则可以通过点击以下链接下载:

2.3 选择包

运行打补丁工具,选择包:

2.4 调整设置

patch-settings.jpg

2.5 调整高级

patch-advanced-settings.jpg

注意,这里如果勾选了检查初始化,打补丁工具会检查 SophixStubApplication 中的代码,不能包含非系统API,会报错。如果使用了 AndroidX 的 MultiDex,工具也会报错,可以先不勾选检查初始化,自行确定SophixStubApplication中无其他非系统API的使用,该工具后面会对AndroidX的库进行识别。

其他选项,请按需自行选择是否勾选。

2.6 生成Patch

点击按钮 GO!,生成补丁。

3. 添加版本

进入移动研发平台 EMAS -> 选择创建的产品 -> 移动热修复 -> 补丁管理,然后点击添加版本按钮,填写版本

特别注意:这里的版本,必须和代码中 setAppVersion 的内容保持完全一致,新版控制台版本号已无限制,字符串即可。

在 SophixStubApplication 的代码中,我们选择的使用App的 versionName 作为 setAppVersion 的参数,那么这里填写的内容就是对应版本 App 的 versionName

我在这里就踩坑了,之前上传 App 到应用市场,应用市场都会读取 Apk 中的版本号,不用手动填写,我以为这里也是一样。以为填写版本号是为了方便用户使用,所以随手就加了 "v" 作为前缀,如下图所示:

结果,在补丁发布后,App始终都检测不到又新补丁,一度怀疑人生。

4. 上传补丁

目前支持的补丁大小最大为30MB,参考自移动热修复:补丁大小有什么限制?

5. 本地测试

下载本地调试工具,把调试工具和 app-release-0.apk 都安装到手机上。

先打开App,再打开调用工具,点击连接应用,

鼠标悬停在提示位置:

点击扫描二维码,安装补丁,重启App,查看App是否生效:

6. 灰度测试

灰度发布的时候,可以指定前面提到的tag。

gray-release-tag.jpg

测试效果:

7. 正式上线

经过本地测试和灰度测试,确认无误后选择全量发布:

8. 热更新结果

变动项 结果
更换Splash的背景图片,不修改图片的名字,注意该图片是放在 drawable 文件下且通过主题设置 未生效
替换首页第一张图片,不修改图片的名字,注意该图片是放在 drawable 文件下 生效
替换首页第二张图片,不修改图片的名字,注意该图片是放在 assets 文件下 未生效
在布局文件中修改按钮文本的颜色 生效
在字符串资源文件中修改按钮的文本 生效
替换我的第一张图片,修改图片的名字,注意该图片是放在 drawable 文件下 生效
替换我的第二张图片,修改图片的名字,注意该图片是放在 assets 文件下 生效
在布局文件中修改按钮文本的颜色 生效
在字符串资源文件中修改按钮的文本 生效
替换我的跳转至百度按钮点击事件中的toast文案 生效

第二次热更新-使用占位Activity

1. 更新代码

  1. 为空的 WebActivity 创建布局文件,增加路由,接收路由参数url,用 WebView 显示 url 的内容。

  2. 我的页面跳转至百度按钮点击,跳转至 webActivity,携带参数为百度网页地址。

  3. 更换Splash的背景图片,修改图片的名字。

2. 生成补丁并发布

重复的步骤不再展开,新生成的包命名为 app-release-2.apk注意选择包的时候,旧包为 app-release-0.apk 而不是 app-release-1.apk

3. 热更新结果

变动项 结果
增加空 WebActivity 内容,展示指定 url 的网页内容 生效
我的页面跳转至百度按钮点击,跳转至 webActivity,携带参数为百度网页地址。 生效
更换Splash的背景图片,修改图片的名字 不生效

第三次热更新-新增So和Style

1. 更新代码

  1. 集成腾讯浏览服务TBS(该框架中包含so文件),将 WebActivity 中的系统 WebView 替换为 TBS 的 WebView。

  2. 进入 WebActivity 的时候显示一个 Toast,确认当前显示的已经集成 TBS 的 Activity。

  3. 在我的页面新增两个TextView,除了位置属性外,其他属性全部使用 Style。第一个TextStyle中部分是实际值,部分是引用,第二个 TextStyle 除了 android:gravity 全部使用引用。

    <style name="UserTestTextStyle1">
        <item name="android:layout_width">80dp</item>
        <item name="android:layout_height">30dp</item>
        <item name="android:gravity">center</item>
        <item name="android:background">@color/common_red_F41</item>
        <item name="android:textSize" tools:ignore="SpUsage">20dp</item>
        <item name="android:textColor">@color/common_white</item>
        <item name="android:text">@string/user_test_style_1</item>
    </style>
    
    <style name="UserTestTextStyle2">
        <item name="android:layout_width">@dimen/TestText2Width</item>
        <item name="android:layout_height">@dimen/TestText2height</item>
        <item name="android:gravity">center</item>
        <item name="android:background">@color/TestText2Background</item>
        <item name="android:textSize" tools:ignore="SpUsage">@dimen/TestText2TextSize</item>
        <item name="android:textColor">@color/TestText2TextColor</item>
        <item name="android:text">@string/user_test_style_2</item>
    </style>
    
    <dimen name="TestText2Width">80dp</dimen>
    <dimen name="TestText2height">30dp</dimen>
    <color name="TestText2Background">#FF4A1E</color>
    <dimen name="TestText2TextSize">20dp</dimen>
    <color name="TestText2TextColor">#FFFFFF</color>
    

2. 生成补丁并发布

重复的步骤不再展开,新生成的包命名为 app-release-3.apk注意选择包的时候,旧包为 app-release-0.apk 而不是 app-release-2.apk

3. 热更新结果

变动项 结果
集成腾讯浏览服务 TBS 生效
WebActivity 进入的时候显示 Toast 生效
我的页面新增TestView,使用 Style,在 Style 中部分使用实际值,部分使用引用 生效
我的页面新增TestView,使用 Style,在 Style 中除了 android:gravity 全部使用引用 生效

第四次热更新-修改Style

1. 更新代码

修改我的页面两个 TextView 的 style,第一个直接修改实际值,第二个修改引用内容的值。

<style name="UserTestTextStyle1">
    <item name="android:layout_width">140dp</item>
    <item name="android:layout_height">50dp</item>
    <item name="android:gravity">center</item>
    <item name="android:background">@color/common_green_07</item>
    <item name="android:textSize" tools:ignore="SpUsage">30dp</item>
    <item name="android:textColor">@color/common_red_F41</item>
    <item name="android:text">@string/user_test_style_1_1</item>
</style>

<style name="UserTestTextStyle2">
    <item name="android:layout_width">@dimen/TestText2Width</item>
    <item name="android:layout_height">@dimen/TestText2height</item>
    <item name="android:gravity">center</item>
    <item name="android:background">@color/TestText2Background</item>
    <item name="android:textSize" tools:ignore="SpUsage">@dimen/TestText2TextSize</item>
    <item name="android:textColor">@color/TestText2TextColor</item>
    <item name="android:text">@string/user_test_style_2</item>
</style>

<dimen name="TestText2Width">140dp</dimen>
<dimen name="TestText2height">50dp</dimen>
<color name="TestText2Background">#07C27F</color>
<dimen name="TestText2TextSize">30dp</dimen>
<color name="TestText2TextColor">#FF4A1E</color>

2. 生成补丁并发布

重复的步骤不再展开,新生成的包命名为 app-release-4.apk注意选择包的时候,旧包为 app-release-0.apk 而不是 app-release-3.apk

3. 热更新结果

变动项 结果
Style直接修改实际值 生效
Style修改引用内容的值 生效

第五次热更新-修改主题

1. 更新代码

修改启动页主题,取消全屏,将状态栏颜色修改为红色。

<style name="SplashTheme" parent="AppTheme">
    <item name="android:windowNoTitle">false</item>
    <item name="android:windowFullscreen">false</item>
    <item name="colorPrimaryDark">@color/common_red_F41</item>
    <item name="android:windowContentOverlay">@null</item>
    <!-- 启动页背景 -->
    <item name="android:windowBackground">@drawable/launch_splash_2</item>
</style>

2. 生成补丁并发布

重复的步骤不再展开,新生成的包命名为 app-release-5.apk注意选择包的时候,旧包为 app-release-0.apk 而不是 app-release-4.apk

3. 热更新结果

modify-theme.gif
变动项 结果
Style直接修改实际值 不生效

总结

经过简单几轮测试下来,热更新可以做到以下几点:

  1. 支持对应用Application的代码调整(非SophixStubApplication);
  2. 支持混淆;
  3. 支持对代码的调整;
  4. 支持 ARouter,开始的时候自己还是有点担心不支持 ARouter
  5. 支持 res 中资源除主题之外的修改。
  6. 支持更新 assest 中的图片,但是必须是新增图片,即图片名称必须修改,不能使用原图片名称。

同样也有一些还做不到的:

  1. 不支持修改 AndroidManifest.xml,所以新增 Activity ,新增权限等都不能生效。也不是没有办法解决,Activity 可以通过提前配置空白 Activity 占位的形式解决。
  2. 不支持修改 Activity 的主题,就算只是更换了 Activity 主题背景图片,无论是该图片是新增,还是保留原图片名称,只替换内容,都不支持。
  3. 不支持更新 assest 中的图片内容,保留原图片名称。

对于官方宣传的应用场景:线上bug紧急修复和快速轻量版本升级,我认为达到了我的预期。

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

推荐阅读更多精彩内容