ReactNative热更新&拆包


目录

  • 1)全量热更新-Android
  • 2)拆包增量更新-Android
  • 3)图片增量更新-Android
  • 4)全量热更新-iOS

流程图

1)全量热更新-Android

全量热更新

-打更新包bundle(包括更新的图片和代码)

react-native bundle --entry-file index.android.js --bundle-output ./bundle/index.android.bundle --platform android --assets-dest ./bundle --dev false
  • 运行此命令会将代码和图片打入根目录下的bundle文件夹,将这些文件压缩至zip包
    => patches.zip
    =>将zip包放入远程文件服务器待下载


    打更新包bundle

-根据业务判断是否需要更新

private void checkVersion() {
  if (true) {
     // 有最新版本
     Toast.makeText(this, "开始下载", Toast.LENGTH_SHORT).show();
     initDownloadManager();  //开启广播接收器
     downLoadBundle();   //开始下载任务
  }
}

-下载zip 至指定的sdcard地址

    //注册广播接收器
    private void initDownloadManager() {
        mDownloadReceiver = new DownloadReceiver();
        registerReceiver(mDownloadReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
    }

    //下载任务
    private void downLoadBundle() {
        // 1.检查是否存在pat压缩包,存在则删除
        // /storage/emulated/0/Android/data/包名/cache/patches.zip
        zipfile = new File(FileConstant.get_JS_PATCH_LOCAL_PATH(this));
        if(zipfile != null && zipfile.exists()) {
            zipfile.delete();
        }
        // 2.下载
        DownloadManager downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
        //远程下载地址http://192.168.1.127/patches.zip
        DownloadManager.Request request = new DownloadManager
                .Request(Uri.parse(FileConstant.JS_BUNDLE_REMOTE_URL));
        request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE);
        request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_MOBILE| DownloadManager.Request.NETWORK_WIFI);
        //下载目标地址 /storage/emulated/0/Android/data/包名/cache/patches.zip
        request.setDestinationUri(Uri.parse("file://"+ FileConstant.get_JS_PATCH_LOCAL_PATH(this)));
        mDownloadId = downloadManager.enqueue(request);
    }

-解析zip并写入sdcard

    private class DownloadReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
            //下载完成,收到广播
            long completeDownloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
            if(completeDownloadId == mDownloadId){
                // 1.解压并写入sdcard对应地址
                RefreshUpdateUtils.decompression(getApplicationContext());
                zipfile.delete();
            }
        }
    }
//~/RefreshUpdateUtils.java
    //解析压缩包,并写入手机存储位置
    public static void decompression(Context context) {
        try {
            //从下载目标地址 /storage/emulated/0/Android/data/包名/cache/patches.zip 获取压缩包
            ZipInputStream inZip = new ZipInputStream(new FileInputStream(FileConstant.get_JS_PATCH_LOCAL_PATH(context)));
            ZipEntry zipEntry;
            String szName;
            try {
                while((zipEntry = inZip.getNextEntry()) != null) {

                    szName = zipEntry.getName();
                    //如果是目录则创建,并写入/storage/emulated/0/Android/data/包名/cache/patches/目录下
                    if(zipEntry.isDirectory()) {

                        szName = szName.substring(0,szName.length()-1);
                        File folder = new File(FileConstant.get_JS_PATCH_LOCAL_FOLDER(context) + File.separator + szName);
                        folder.mkdirs();

                    }
                    //如果是文件则创建,并写入/storage/emulated/0/Android/data/包名/cache/patches/目录下
                    else{
                        File folder = new File(FileConstant.get_JS_PATCH_LOCAL_FOLDER(context) + File.separator);
                        if (!folder.exists()){
                            folder.mkdir();
                        }
                        File file1 = new File(FileConstant.get_JS_PATCH_LOCAL_FOLDER(context) + File.separator + szName);

                        boolean s = file1.createNewFile();
                        FileOutputStream fos = new FileOutputStream(file1);
                        int len;
                        byte[] buffer = new byte[1024];

                        while((len = inZip.read(buffer)) != -1) {
                            fos.write(buffer, 0 , len);
                            fos.flush();
                        }

                        fos.close();
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            inZip.close();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

-RN调用JSBundle的时候判断,当sdcard对应位置的bundle不为空时加载sdcard中的bundle,否则加载原包内Assets位置的bundle


public class MainApplication extends Application implements ReactApplication {
    private ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
        @Override
        public boolean getUseDeveloperSupport() {
            //Debug模式,这个模式才能在JS里作调试
            return BuildConfig.DEBUG;
        }

        @Override
        protected List<ReactPackage> getPackages() {
            //返回带有官方已有的package的集合
            return Arrays.<ReactPackage>asList(
                    new MainReactPackage(),
                    new MyReactPackage()  //加入自定义的Package类
            );
        }

        @Nullable
        @Override
        protected String getJSBundleFile() {
            //判断sdcard中是否存在bundle,存在则加载,不存在则加载Assets中的bundle
            //路径 /storage/emulated/0/Android/data/包名/cache/patches/index.android.bundle
            File file = new File (FileConstant.get_JS_BUNDLE_LOCAL_PATH(getApplicationContext()));
            if(file != null && file.exists()) {
                return FileConstant.get_JS_BUNDLE_LOCAL_PATH(getApplicationContext());
            } else {
                return super.getJSBundleFile();
            }
        }
    };

    @Override
    public ReactNativeHost getReactNativeHost() {
        return mReactNativeHost;
    }

    @Override
    public void onCreate() {
        super.onCreate();
        SoLoader.init(this, /* native exopackage */ false);
    }
}

-注意点:

  • 1-退出的时候需要杀死进程,否则不会初始化Application就无法更换bundle的加载路径了
@Override
  protected void onDestroy() {
    super.onDestroy();
    //杀死进程,否则就算退出App,App处于空进程并未销毁,再次打开也不会初始化Application
    //从而也不会执行getJSBundleFile去更换bundle的加载路径 !!!
    android.os.Process.killProcess(android.os.Process.myPid());
    //解除广播接收器
    unregisterReceiver(mDownloadReceiver);
}
  • 2-权限,由于我们采用App扩展存储方式,若无需兼容6.0以下,则无需申请权限。
<!--代码中使用getExternalCacheDir(), API >=19 是不需要申请的,若需兼容6.0以下则需写此权限 -->
<!--但写此权限若不加maxSdkVersion="18",会导致6.0已上机型会在设置中看到此权限开关,从而可能会关闭此权限-->>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="18"/>
  • 3-不能在开发环境调试,需要打包调测
//创建assets目录 ./android/app/src/main/assets
//创建离线bundle和打包本地资源
react-native bundle --entry-file index.android.js --bundle-output ./android/app/src/main/assets/index.android.bundle --platform android --assets-dest ./android/app/src/main/res/ --dev false
//打签名包即可
cd android && ./gradlew assembleRelease
//进入目录安装apk  ./android/app/build/outputs/apk/release
adb install app-release.apk 

-其他代码

~/FileConstant.java

public class FileConstant {
    //远程下载服务地址
    public static final String JS_BUNDLE_REMOTE_URL = "http://192.168.1.127/patches.zip";
    //本地bundle文件名
    public static final String JS_BUNDLE_LOCAL_FILE = "index.android.bundle";

    //sdcard中bundle的加载路径
    public static String get_JS_BUNDLE_LOCAL_PATH(Context context){
        return context.getExternalCacheDir().getPath() + File.separator+ "patches/index.android.bundle";
    }
    //sdcard中下载后文件的存放文件夹路径
    public static String get_JS_PATCH_LOCAL_FOLDER(Context context){
        return context.getExternalCacheDir().getPath() + File.separator+ "patches";
    }
    //sdcard中下载的zip包存放位置
    public static String get_JS_PATCH_LOCAL_PATH(Context context){
        return context.getExternalCacheDir().getPath() + File.separator+ "patches.zip";
    }
    //sdcard中下载后的增量包pat存放位置
    public static String get_JS_PATCH_LOCAL_FILE(Context context){
        return context.getExternalCacheDir().getPath() + File.separator+ "patches/patches.pat";
    }
}

2)拆包增量更新-Android

  • 虽然通过zip压缩减小了一部分bundle体积,但是每次需要热更新去打全量包在不更新图片的情况下再小也有几百kb,其中业务部分的代码也只占一部分,着实浪费且不科学。
  • 所以在每次原生迭代版本发布时,保留其附属的RN版本bundle,并在此原生版本周期内需要热更新时,生成新的bundle,使用google-diff-match-patch与原版本bundle比对,生成差异化补丁。
  • App判断有热更新时,下载此补丁,与Assets中的初始版本合并,生成新的index.android.bundle文件写入sdcard中。

-打更新包bundle(同-1)

-生成差异化补丁文件

将初始版本old.bundle和热更版本new.bundle进行比对,生成patches.pat
=> 将pat和图片压缩成 patches.zip
=> 将zip包放入远程文件服务器待下载


生成patches.pat

patches.pat
    public static void main(String[] args) {
        String o = getStringFromPat("/Users/tugaofeng/Desktop/old.bundle");
        String n = getStringFromPat("/Users/tugaofeng/Desktop/new.bundle");
        // 对比
        diff_match_patch dmp = new diff_match_patch();
        LinkedList<diff_match_patch.Diff> diffs = dmp.diff_main(o, n);
        // 生成差异补丁包
        LinkedList<diff_match_patch.Patch> patches = dmp.patch_make(diffs);
        // 解析补丁包
        String patchesStr = dmp.patch_toText(patches);

        try {
            // 将补丁文件写入到某个位置
            Files.write(Paths.get("/Users/tugaofeng/Desktop/patches.pat"), patchesStr.getBytes());
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }

-根据业务判断是否需要更新(同-1)

-下载zip 至指定的sdcard地址(同-1)

-解析zip并写入sdcard

    private class DownloadReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
            //下载完成,收到广播
            long completeDownloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
            if(completeDownloadId == mDownloadId){
                // 1.解压并写入sdcard对应地址
                RefreshUpdateUtils.decompression(getApplicationContext());
                zipfile.delete();

                // 2.将下载好的patches文件与assets目录下的原index.android.bundle合并,得到新的
                // bundle文件,并写入sdcard中
                mergePatAndAsset();
            }
        }
    }

-将Assets内的index.android.bundle和下载完成的差异化补丁pat合并,并生成新的index.android.bundle写入sdcard对应位置

//拆包增量更新bundle
    private void mergePatAndAsset() {
        // 1.获取本地Assets目录下的bunlde
        String assetsBundle = RefreshUpdateUtils.getJsBundleFromAssets(getApplicationContext());
        // 2.获取.pat文件字符串
        // /storage/emulated/0/Android/data/包名/cache/patches/patches.pat
        String patcheStr = RefreshUpdateUtils.getStringFromPat(FileConstant.get_JS_PATCH_LOCAL_FILE(this));
        if (patcheStr == null || "".equals(patcheStr)){
            return;
        }
        // 3.初始化 dmp
        diff_match_patch dmp = new diff_match_patch();
        // 4.转换pat
        LinkedList<diff_match_patch.Patch> pathes = (LinkedList<diff_match_patch.Patch>) dmp.patch_fromText(patcheStr);
        // 5.与assets目录下的bundle合并,生成新的bundle
        Object[] bundleArray = dmp.patch_apply(pathes,assetsBundle);
        // 6.保存新的bundle
        // 至/storage/emulated/0/Android/data/包名/cache/patches/index.android.bundle
        try {
            Writer writer = new FileWriter(FileConstant.get_JS_BUNDLE_LOCAL_PATH(this));
            String newBundle = (String) bundleArray[0];
            writer.write(newBundle);
            writer.close();
            // 7.删除.pat文件
            // 路径为/storage/emulated/0/Android/data/包名/cache/patches/patches.pat
            File patFile = new File(FileConstant.get_JS_PATCH_LOCAL_FILE(this));
            patFile.delete();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

-其他代码

~/diff_match_patch.java

//~/RefreshUpdateUtils.java
    //将.pat or bundle文件转换为String
    public static String getStringFromPat(String patPath) {
        FileReader reader = null;
        String result = "";
        try {
            reader = new FileReader(patPath);
            int ch = reader.read();
            StringBuilder sb = new StringBuilder();
            while (ch != -1) {
                sb.append((char)ch);
                ch  = reader.read();
            }
            reader.close();
            result = sb.toString();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return result;
    }

    //从本地Assets获取bundle
    public static String getJsBundleFromAssets(Context context) {
        String result = "";
        try {
            InputStream is = context.getAssets().open(FileConstant.JS_BUNDLE_LOCAL_FILE);
            int size = is.available();
            byte[] buffer = new byte[size];
            is.read(buffer);
            is.close();
            result = new String(buffer,"UTF-8");

        } catch (IOException e) {
            e.printStackTrace();
        }
        return result;
    }

3)图片增量更新-Android

图片增量更新需要修改RN源码。

-修改RN源码。
注意:RN库版本升级时别忘了修改。。

渲染图片的方法在:node_modules / react-native / Libraries / Image /AssetSourceResolver.js 下:

defaultAsset(): ResolvedAssetSource {  
  if (this.isLoadedFromServer()) {  
    return this.assetServerURL();  
  }  
  
  if (Platform.OS === 'android') {  
    return this.isLoadedFromFileSystem() ?  
//存在离线Bundle文件时,从Bundle文件所在目录加载图片
      this.drawableFolderInBundle() :  
//否则从Asset资源目录下加载
      this.resourceIdentifierWithoutScale();  
  } else {  
    return this.scaledAssetPathInBundle();  
  }  
}  

对源码做如下修改:

...
import type { PackagerAsset } from 'AssetRegistry';
// 1-新增全局变量
// !!!注意:每次基于某个原生版本的RN热更版本新增的图片都要在此处新增加(不是覆盖哦)
// 比如原生版本1.0.0,RN热更版本1.0.0-1时新增a.png
//var patchImgNames = '|a.png|';
// 比如原生版本1.0.0,RN热更版本1.0.0-2时新增b.png
//var patchImgNames = '|a.png|b.png|';
// 比如原生版本2.0.0(2.0的原生版本asset里已经会包含a和b.png),暂无RN热更版本时
//var patchImgNames = '';
var patchImgNames = '|src_res_images_offer_message_red.png|src_res_images_banner_default.png|'; 

...
// 2-修改此函数
  isLoadedFromFileSystem(): boolean {
    // return !!this.bundlePath;  //注释此处,新增如下代码
    var imgFolder = getAssetPathInDrawableFolder(this.asset);  
    var imgName = imgFolder.substr(imgFolder.indexOf("/") + 1);  
    var isPatchImg = patchImgNames.indexOf("|"+imgName+"|") > -1;  
    return !!this.bundlePath && isPatchImg; 
  }

-打增量代码包和增量图片

=> 打更新包
=> 生成差异化补丁文件pat
=> 将pat和本次热更新增的图片压缩成patches.zip
=> 将zip包放入远程文件服务器待下载


第一次热更包 1.0.0-1

第二次热更包1.0.0-2
  • 注意:图片每次热更的时候可以只将当次热更新增的图片打入zip包中,而不需要像修改源码全局变量patchImgNames一样需要追溯当次原生版本的所有热更图片的文件名 !!!

-效果展示

版本1.0.0热更至1.0.0-1.gif
版本1.0.0-1的sdcard图片目录.png

版本1.0.0-1热更至1.0.0-2.gif

版本1.0.0-2的sdcard图片目录.png

4)全量热更新-iOS

demo

-修改podfile,新增SSZipArchive【解压】和AFNetworking【文件下载】

  pod 'SSZipArchive'
  pod 'AFNetworking', '~> 3.0'
cd /ios && pod install

-打更新包bundle(包括更新的图片和代码)

react-native bundle --entry-file index.ios.js --platform ios --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_ios/
  • 运行此命令会将代码和图片打入根目录下的release_ios文件夹,将这些文件压缩至zip包
    => patches.zip
    =>将zip包放入远程文件服务器待下载

-创建bundle存放路径,使用plist文件去存储版本号和下载路径

//创建bundle路径
-(void)createPath{
    
    NSFileManager *fileManager = [NSFileManager defaultManager];
    if ([fileManager fileExistsAtPath:[self getVersionPlistPath]]) {
        return;
    }
    
    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES);
    NSString *path = [paths lastObject];
    NSString *directryPath = [path stringByAppendingPathComponent:@"IOSBundle"];
    [fileManager createDirectoryAtPath:directryPath withIntermediateDirectories:YES attributes:nil error:nil];
    NSString *filePath = [directryPath stringByAppendingPathComponent:@"Version.plist"];
    [fileManager createFileAtPath:filePath contents:nil attributes:nil];
}

-根据业务判断是否需要更新

//获取版本信息
-(void)getAppVersion{
    
    //从服务器上获取版本信息,与本地plist存储的版本进行比较
    //1.获取本地plist文件的版本号 
    NSString* plistPath=[self getVersionPlistPath];
    NSMutableDictionary *data = [[NSMutableDictionary alloc] initWithContentsOfFile:plistPath];
    
    NSInteger localV=[data[@"bundleVersion"]integerValue];
  
    //本地plist的版本号
    printf("%ld ", (long)localV);

    //保留业务,根据当前热更版本号与本地比对,进行判断是否下载
    if(true){
        //下载bundle文件 存储在 Doucuments/IOSBundle/下
        NSString*url=@"http://192.168.1.127/patches.zip";
        [[DownLoadTool defaultDownLoadTool] downLoadWithUrl:url];
    }
}

-下载zip 至指定沙盒路径地址

-(void)downLoadWithUrl:(NSString*)url{
    //根据url下载相关文件
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
    AFURLSessionManager *manager = [[AFURLSessionManager alloc] initWithSessionConfiguration:configuration];
    NSURL *URL = [NSURL URLWithString:url];
    NSURLRequest *request = [NSURLRequest requestWithURL:URL];
    NSURLSessionDownloadTask *downloadTask = [manager downloadTaskWithRequest:request progress:^(NSProgress * _Nonnull downloadProgress) {
        //获取下载进度
        NSLog(@"Progress is %f", downloadProgress.fractionCompleted);
    } destination:^NSURL *(NSURL *targetPath, NSURLResponse *response) {
        //有返回值的block,返回文件存储路径
        NSURL *documentsDirectoryURL = [[NSFileManager defaultManager] URLForDirectory:NSDocumentDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:NO error:nil];
        
        // file:///Users/tugaofeng/Library/Developer/CoreSimulator/Devices/C4A3CBA7-3313-4EF1-A281-FF04064041B0/data/Containers/Data/Application/20FC6C6B-1397-45C7-A7C5-836EA272EE1C/Documents/kiOSFileName
        NSURL* targetPathUrl = [documentsDirectoryURL URLByAppendingPathComponent:@"IOSBundle"];
        return [targetPathUrl URLByAppendingPathComponent:[response suggestedFilename]];
        
    } completionHandler:^(NSURLResponse *response, NSURL *filePath, NSError *error) {
        if(error){
            //下载出现错误
            NSLog(@"%@",error);
            
        }else{
            // [self showPromptWithStr:@"更新完毕。请重新启动******!"];
            //下载成功
            //  file:///Users/tugaofeng/Library/Developer/CoreSimulator/Devices/C4A3CBA7-3313-4EF1-A281-FF04064041B0/data/Containers/Data/Application/20FC6C6B-1397-45C7-A7C5-836EA272EE1C/Documents/kiOSFileName/patches.zip
            NSLog(@"File downloaded to: %@", filePath);
            self.zipPath = [[filePath absoluteString] substringFromIndex:7];
            //下载成功后更新本地存储信息
            NSDictionary*infoDic=@{@"bundleVersion":@3,@"downloadUrl":url};
            [UpdateDataLoader sharedInstance].versionInfo=infoDic;
            
            [[UpdateDataLoader sharedInstance] writeAppVersionInfoWithDictiony:[UpdateDataLoader sharedInstance].versionInfo];
            
            //解压并删除压缩包
            [self unZip];
            [self deleteZip]; 
        }
    }];
    [downloadTask resume];
}

-解压&删除压缩包

//解压压缩包
-(BOOL)unZip{
    if (self.zipPath == nil) {
        return NO;
    }
//Users/tugaofeng/Library/Developer/CoreSimulator/Devices/C4A3CBA7-3313-4EF1-A281-FF04064041B0/data/Containers/Data/Application/20FC6C6B-1397-45C7-A7C5-836EA272EE1C/Documents/kiOSFileName/patches.zip
    NSString *zipPath = self.zipPath;
    
    // /Users/tugaofeng/Library/Developer/CoreSimulator/Devices/C4A3CBA7-3313-4EF1-A281-FF04064041B0/data/Containers/Data/Application/20FC6C6B-1397-45C7-A7C5-836EA272EE1C/Documents/IOSBundle
    NSString *destinationPath = [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0]stringByAppendingString:@"/IOSBundle"];
    BOOL success = [SSZipArchive unzipFileAtPath:zipPath
                                   toDestination:destinationPath];
    return success;  
}  
//删除压缩包  
-(void)deleteZip{  
    NSError* error = nil;  
    [[NSFileManager defaultManager] removeItemAtPath:self.zipPath error:&error];  
}  
下载前沙盒文件

下载解压后沙盒文件

-RN调用JSBundle的时候判断,当沙盒对应位置的bundle不为空时加载其bundle,否则加载原包内的bundle

    NSURL *jsCodeLocation;
    
    NSString* iOSBundlePath = [[UpdateDataLoader sharedInstance] iOSFileBundlePath];
    NSString* filePath = [iOSBundlePath stringByAppendingPathComponent:@"/main.jsbundle"];
    if ([[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
            jsCodeLocation = [NSURL URLWithString:[iOSBundlePath stringByAppendingString:@"/main.jsbundle"]];
    
    }else{
        jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
    }

参考资料

React Native 实现热部署、差异化增量热更新
React-Native开发iOS篇-热更新的代码实现

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