RN熱更新原理

熱更新

ReactNative告別CodePush,自建熱更新版本升級環境

微軟的CodePush熱更新非常難用大家都知道,速度更墻了沒什么區別。

另一方面,加入不希望代碼放到別人的服務器上,自己寫接口更新總歸安全一些。

那如何自己做一個ReactNative更新管理工具。

ReactNative啟動原理

首先我們要弄清react-native啟動的原理,是直接調用jslocation中的jsbundle文件和assets資源文件。

由此,我們可以自己通過請求服務器接口來判斷版本,并下載最新的然后替換相應的文件,然后從這個文件調用啟動APP。這就像之前的一些H5 APP一樣的做版本的管理。

以iOS為例,我們需要分一下幾個步驟搭建自己的RN升級工具:

一、設置默認jsbundle地址(比如document文件夾)

1.首先打包的時候把jsbundle和assets放入copy bundle resource,每次啟動后,檢測document文件夾是否存在,不存在則拷貝到document文件夾,然后給RN框架讀取啟動。

我們建立如下的bundle文件管理類:

MXBundleHelper.h

#import <Foundataion/Foundation.h>
@interface MXBundleHelper : NSObject
  
+(NSURL *)getBundlerPath;

@end

MXBundlerHelper.m

#import "MaxBundleHelper.h"
#import "RCTBundleURLProvider.h"
@implementation MABundleHelper
+(NSURL *)getBundlePath {
  #ifdef DEBUG
  NSURL * jsCodeLocation = [[RCTBundleURLProvider sharedSetting] jsBundleURLForBundleRoot:@"index.ios" fallbackResource: nil];
  return jsCodeLocation;
  #else
  // 需要存放和讀取的document路徑
  // jsbundle地址
  NSString * jsCachePath = [NSString stringWithFormat:@"%@/\%@",NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask, Yes)[0],@"main.jsbundle"];
  // assets文件夾地址
  NSString *assetsCachePath = [NSString stringWithFormat:@"%@/\%@",NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0],@"assets"];
  
  // 判斷JSBundle是否存在
  BOOL jsExist = [[NSFileManager defaultManager] fileExistsAtPath: jsCachePath];
  // 如果已經存在
  if (jsExist) {
    NSLog(@"js已存在:%@",jsCachePath);
  } else {
    // 如果不存在
    NSString * jsBundle = [[NSBundle mainBundle] pathForResource:@"main" ofType:@"jsbundle"];
    [[NSFileManager defaultManager] copyItemAtPath: jsBundlePath toPath:jsCache error:nil];
    NSLog(@"js已拷貝到Document: %@", jsCachePath);
  }
  
  // 判斷assets是否存在
  BOOL assetsExist = [[NSFileManager defaultManager] fileExistsAtPath: assetsCachePath];
  // 如果已存在
  if (assetsExist) {
    NSLog(@"assets已存在:%@",assetsCachePath);
  } else {
    NSString *assetsBundlePath = [[NSBundle mainBundle] pathForResource:@"assets" ofType: nil];
    [[NSFileManager defaultManager] copyItemAtPath: assetsBundlePath toPath: assetsCachePath error: nil];
    NSLog(@"assets已拷貝至Document:%@",assetsCachePath);
  }
  return [NSURL URLWithString: jsCachePath];
#endif
}

二、做升級檢測,有更新則下載,然后對本地文件進行替換:

加入我們不立即做更新,可以更新后替換,然后不會影響本次APP的使用,下次使用就會默認是最新的了。如果立即更新的話,需要使用到RCTBridge類里的relaod函數進行重啟。

這里通過NSURLSession進行下載,然后zip解壓縮等方法來實現文本的替換。

MXUpdateHelper.h

#import <Foundation/Foundation.h>
typedef void(^FinishBlock) (NSInteger status, id data);
@interface MXUpdateHelper : NSObject
+(void)checkUpdate:(FinishBlock)finish;
@end

MXUpdateHelper.m

#import "MXUpdateHelper.h"

@implementation MXUpdateHelper
+(void)checkupdate:(FinishBlock)finish {
  NSString *url = @"http://www.xxx.com/xxxx";
  NSMutableURLRequest * newRequest = [[NSMutableURLRequest alloc] initWithURL:[NSURL URLWithString: url]];
  [newRequest setHTTPMethod:@"GET"];
  [NSURLConnection sendAsynchronousRequest: newRequest queue:[NSOperationQueue mainQueue] completionHandler: ^(NSURLResponse * response, NSData * data, NSError * connectionError) {
    if (connectionError == nil) {
      // 請求自己服務器的API,判斷當前的JS版本是否最新
      /*{
        "version": "1.0.5",
        "fileUrl":"http://www.xxxx.com/xxx.zip",
        "message": "有新版本,請更新到我們最新的版本",
        "forecUpdate": "NO"
      }*/
      // 加入需要更新
      NSString * curVersion = @"1.0.0";
      NSString * newVersion = @"2.0.0"
      // 一般情況下不一樣,就是舊版本了
      if (![curVersion isEqualToString: newVersion]) {
        finish(1,data);
      } else {
        finish(0,nil);;
      }
    }
  }];
}
@end

三、APPdelegate中的定制、彈框,直接強制更新等

如果需要強制刷新reload,我們新建RCTView的方式也需要稍微修改下,通過新建一個RCTBridge的對象。因為RCTBridge中有reload的接口可以使用。

#import "AppDelegate.h"
#import "RCTBundleURLProvider.h"
#import "RCTRootView.h"
#import "MXBundleHelper.h"
#import "MXUpdateHelper.h"
#import "MXFileHelper.h"
#import "SSZipArchive.h"
@interface AppDelegate()<UIAlertViewDelegate>
@property (nonatomic, strong) RCTBridge *bridge;
@property (nonatomic, strong) NSDictionary *versionDic;
@end

@implementation Appdelegate
- (BOOL) application:(UIApplication *)application didFinishLaunchingWithOptions: (NSDictionary *)launchOptions {
  NSURL *jsCodeLocation;
  jsCodeLocation = [MXBundleHelper getBundlePath];
  
  _bridge = [[RCTBridge alloc] initWithBundleURL: jsCodeLocation mouduleName:@"MXVersionManger" initialProperties: nil];
  
  rootView.backgroundColor = [UIColor alloc] initWithRed: 1.0f green:1.0f blue:1.0f alpha:1];
  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  UIViewController *rootViewController = [UIVeiwController new];
  rootViewController.view = rootView;
  self.window.rootViewController = rootViewController;
  
  [self.window makeKeyAndVisible];
  
  __weak AppDelegate *weakself = self;
  // 更新檢測
  [MXUpdateHelper checkUpdate:^(NSInteger status, id data) {
    if (status == 1) {
      wekself.versionDic = data;
      /*
      這里具體關乎用戶體驗的方式就多種多樣了,比如自動立即更新,彈框立即更新,自動下載打開再更新等。
      */
      UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"提示" message:data[@"message"] delegatee:self cancelButtonTitle:@"取消" otherButtonTitle:@"現在更新", nil];
      [alert show];
      // 進行下載,并更新
      //  下載完,覆蓋JS和assets,并reload界面
    }
  }];
  return YES;
}

-(void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex {
  if (buttonIndex == 1) {
    // 更新
    [[MXFileHelper shared] downloadFileWithURLString: _versionDic[@"fileurl"] finish:^(NSInteger status, id data) {
      if (status == 1) {
        NSLog(@"下載完成");
        NSError *error;
        NSString *filePath = (NSString *)data;
        NSString *desPath = [NSString stringWithFormat:@"%@",NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0]];
        [SSZipArchive unzipFileAtPath: filePath toDestination: desPath overwrite: YES password:nil error:&error];
        if (!error) {
          NSLog(@"解壓成功");
          [_bridge reload];
        } else {
          NSLog(@"解壓失敗");
        }
      }
     }];
  }
}

流程簡單,通過接口請求版本,然后下載到document去訪問。其中需要做版本緩存,Zip的解壓縮,以及文件拷貝。

// demo: https://github.com/rayshen/MXHotdog

差異化更新

以上我們完成了代碼的熱更新工作。但是如果bundle太大的情況下,會增加用戶的流浪消耗,我們可以用生成補丁包的方式來進一步減少更新包zip的體積。

以安卓為例:

促使化項目發布時,生成并保留一份index.android.bundle文件。

有版本更新時,生成新的index.android.bundle文件,使用google-diff-match-patch對比兩個文件,并生成差異不定文件。app下載補丁文件,在使用google-diff-match-patch和assets目錄下的初始版本合并,生成新的index.android.bundle文件

1.添加google-diff-match-patch庫

google-diff-match-patch庫包含了多種編程語言的庫文件,我們使用其中的java版本,所以我們將其的提取出來,方便大家下載使用:

http://download.csdn.net/detail/u013718120/9833398

下載之后添加到項目目錄即可

2.生成補丁包

String oldPackeg = RefreshUpdateUtils.getStringFromPat(oldPath);
String newPackeg = RefreshUpdateUtils.getStringFromPat(newPath);

// 對比
diff_match_patch dmp = new diff_match_patch();
LinkedList<Diff> diffs = dmp.diff_main(oldPackeg, newPackeg);

// 生成差異補丁包
LinkedList<Patch> patches = dmp.patch_make(diffs);

// 解析補丁包
String patchesStr = dmp.patch_toText(patches);

try {
  // 將補丁寫入到某個位置
  Files.write(Paths.get("targetPath"), pathcesStr.getBytes());
} catch (IOException e) {
  e.printStacckTrace();
}
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;
}

3.下載完成,解壓后執行mergePatAndAsset方法將Assets目錄下的index.android.bundle和pat文件合并

/**
* 下載完成后收到廣播
*/
publci class CompleteReceiver extends BroadcastReceiver {
  @Override
  public void onReceive(Context context, Intent intent) {
    long completeId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
    if (completeID == mDownLoadId) {
      // 1. 解壓
      RefreshUpdateUtils.decompression();
      zipfile.delete();
      // 2. 將下載好的patches文件與assets目錄下的原index.android.bundle合并,得到新的bundle文件
      mergePatAndAsset();
      startActivity(new Intent(MainActivity.this, MyReactActivity.class));
    }
  }
}

4、合并

/**
* 合并patches文件
*/
private void mergePatAndAsset() {
  // 1. 獲取Assets目錄下的bundle
  String assetsBundle = RefreshUpdateUtils.getJsBundleFromAssets(getApplicationContext());
  // 2. 獲取.pat淄川
  String patchStr = RefreshUpdateUtils.getStringFromPat(FileConstant.JS_PATCH_LOCAL_FILE);
  // 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
  try {
    Writer writer = new FileWriter(FileConstant.JS_BUNDLE_LOCAL_PATH);
    String newBundle = (String) bundleArray[0];
    writer.write(newBundle);
    writer.close();
    // 7. 刪除.pat文件
    File patFile = new File(FileConstant.JS_PATCH_LOCAL_FILE);
    patFile.delete();
  } catch(IOException e) {
    e.printStackTrace();
  }
}

總結下來,合并分為如下過程:

(1)獲取Assets目錄下的bundle文件,轉換為字符串。

(2)解析.pat文件將其轉換為字符串。

(3)調用patch_fromText獲取patches補丁包。

(4)調用patch_apply方法將第四步中生成patches補丁包與第一步中獲取的bundle合并生成新的bundle。

(5)保存bundle。

6.讀取Assets目錄下的bundle文件

/**
* 獲取Assets目錄下的bundle文件
* @return
*/
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;
}

以上步驟執行完成后,我們就獲取了新的bundle文件,繼而加載新的bundle文件,實現React Native熱更新。上述差異包更新方式只能更新不含圖片引用的bundle代碼文件,如果需要增量更新文件,需要修改React Native源碼。

四、修改React Native 圖片加載源碼

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

defaultAsset(): ResolveAssetSource {
  if (this.isLoadedFromServer()) {
    return this.assetServerURL();
  } 
  
  if (Platform.OS === 'android') {
    return this.isLoadedFromFileSystem() ?
      this.drawableFolderInBundle() : this.resourceIdentifierWithoutScale();
  } else {
    return this.scaledAssetPathInBundle();
  }
}

defaultAsset方法中根據平臺的不同分別執行不同的圖片加載邏輯。這里主要看android platform:drawableFolderInBundle方法為在存在離線Bundle文件時,從Bundle文件所在目錄加載圖片。resourceIdentifierWithoutScale方法從Asset資源目錄下加載。由此,我們需要修改isLoadedFromFileSystem方法中的邏輯。

(1)在AssetSourceResolver.js中增加增量圖片全局名稱變量

'use strict';

export type ResolvedAssetSource = {
  __packager_asset: boolean,
  width: number,
  height: number,
  uri: string,
  scale: number,
};

import type { PackagerAsset } from 'AssetRegistry';
// 全局緩存 
var patchImgNames = ''; // 新加的代碼
const PixelRatio = require('PixelRatio');
...

(2)修改isLoadedFromFileSystem方法

/* 原代碼
* isLoadedFromFileSystem(): boolean {
*    return !!this.bundlePath;
* }
*/
isLoadedFromFileSystem(): boolean {
  var imgFolder = getAssetPathInDrawableFolder(this.asset);
  var imgName = imgFolder.substr(imgFolder.indexOf("/")+1);
  var isPatchImg = patchImgNames.indexOf("|" + imgName + "|") > -1;
  return !!this.bundlePath && isPathcImg;
}

patchImgNames是增量更新的圖片名稱字符串全局緩存,其中包括所有更新和修改的圖片名稱,并且以“|”隔開。當系統加載圖片時,如果在緩存中存在該圖片名稱,證明是我們增量更新或修改的圖片,所以需要系統從Bundle文件所在目錄下加載。否則直接從原有asset資源加載。

(3)每當有圖片增量更新,修改patchImgName,例如images_ic_1.png和images_ic_2.png為增量更新或修改的圖片。

var patchImgNames = '|images_ic_1.png|images_ic_2.png|';

注:生成bundle目錄時,圖片資源都會放在同一目錄下(drawable-mdpi),如果引用圖片包含其他路徑,例如require("./img/test1.png"),圖片在img目錄下,則圖片加載時會自動將img目錄轉換為圖片名稱:“img_test1.png”,即圖片所在文件夾名稱會作為圖片名的前綴。此時圖片名配置文件中的名稱也需要聲明為"img_test1.png",例如:"|img_test1.png|img_test2.png|"

(4)重新打包

react-native bundle --entry-file index.android.js --bundle-output ./bundle/index.android.bundle--platform android --assets-dest ./bundle --dev false

(5)生成.pat差異補丁包,并壓縮為zip更新包

更新包沒有太大區別,依然是增量更新的圖片和pat。

小提示:

因為RN會從drawable-mdpi下加載圖片,所以我們只需要將drawable-mdpi打包即可,其余的,drawalbe-xx文件夾可以不放進zip。

(6)既然是增量更新,就會分為第一次更新前雨后的情況。所以需要聲明一個標識來表示當前是否為第一次下發更新包

第一次更新前:

1.緩存中不存在更新包,pat補丁包需要與Asset下的index.android.bundle進行合并,生成新的bundle文件。

2.增量圖片直接下發到緩存中。

第一次更新后,即第一次更新后的更新操作:

1.緩存下存在更新包,需要將新的pat補丁包與緩存下上次生成的index.android.bundle進行合并,生成新的bundle文件。

2.增量圖片需要添加到緩存bundle所在文件下的drawable-mdpi目錄。

本次下發的更新包與之前的bundle進行合并以及將圖片添加到之前drawable-mdpi后,需要刪除。

核心代碼如下:

// 下載前檢查本地是否存在更新包。FIRST_UPDATE來標識是否為第一次下發更新包
bundleFile = new File(FileConstant.LOCAL_FOLDER);
if (bundleFile != null && bundleFile.exists()) {
       ACache.get(getApplicationContext()).put(AppConstant.FIRST_UPDATE, false);
} else {
  // 第一次更新
   ACache.get(getApplicationContext()).put(AppcONSTANT.FIRST_UPDATE, true);
}
/**
 * 下載完成后,處理ZIP壓縮包
 */
private void handleZIP() {
  // 開啟單獨線程,解壓,合并
  new Thread(new Runnable() {
    @Override
    public void run() {
      boolean result = (Boolean) ACache.get(getApplicationContext()).getAsObject(AppConstant.FIRST_UPDATE);
        if (result) {
        // 解壓到根目錄              
        FileUtils.decompression(FileConstant.JS_PATCH_LOCAL_FOLDER);
            // 合并
            mergePatAndAsset();
        } else {
            // 解壓到future目錄                 
          FileUtils.decompression(FileConstant.FUTURE_JS_PATCH_LOCAL_FOLDER);
            // 合并
            mergePatAndBundle();
        }
            // 刪除ZIP壓縮包
                FileUtils.deleteFile(FileConstant.JS_PATCH_LOCAL_PATH);
    }
  }).start();
}
/**
 * 與Asset資源目錄下的bundle進行合并
 */
private void mergePatAndAsset() {
  // 解析Asset目錄下的bundle文件
  String assetsBundle = FileUtils.getJsBundleFromAssets(getApplicationContext());
  // 解析bundle當前目錄下.pat文件字符串
  String patcheStr = FileUtils.getStringFromPat(FileConstant.JS_PATCH_LOCAL_FILE);
  // 合并
  merge(patcheStr, assetsBundle);
  // 刪除pat
  FileUtils.deleteFile(FileConstant.JS_PATCH_LOACL_FILE);
}
/**
 * 與本地下的bundle進行合并
 */
private void mergePatAndBundle() {
  // 解析本地目錄下的bundle
  String assetsBundle = FileUtils.getJsBundleFromSDCard(FileConstant.JS_BUNDLE_LOACL_PATH);
  // 解析最新下發的.pat文件字符串
  String patcheStr = FileUtils.getStringFromPat(FileConstant.FUTURE_PAT_PATH);
  // 合并
  merge(patchesStr, assetsBundle);
  // 添加圖片
  FileUtils.copyPatchImgs(FileConstant.FUTURE_DRAWABLE_PATH, FileConstant.DRAWABLE_PATH);
  // 刪除本次下發的更新文件
  FileUtils.traversalFile(FileConstant.FUTURE_JS_PATCH_LOACAL_FOLDER);
}
/**
 * 合并,生成新的bundle文件
 */
private void merge(String patcheStr, String bundle) {
  // 初始化dmp
  diff_match_patch dmp = new diff_match_patch();
  // 轉化pat
  LinkedList<diff)match_patch.Patch> pathes = (LinkedList<diff_match_patch.Patch>)dmp.patch_fromText(patcheStr);
  // pat與bundle合并,并生成新的bundle
  Object[] bundleArray = dmp.patch_apply(patches, bundle);
  // 保存新的bundle文件
  try {
    Writer writer = new FleWriter(FileConstant.JS_BUNDLE_LOCAL_PATH);
    String newBundle = (String)bundleArray[0];
    writer.write(newBundle);
    writer.close();
  } catch (IOExcepiton e) {
    e.printStackTrace();
  }
}

FileUtils 工具類函數

/**
 * 將圖片復制到bundle所在文件夾下的drawable-mdpi
 * @param srcFilePath
 * @param destFilePath
 */
public static void copyPatchImgs(String srcFilePath, String destFilePath) {
  File root = new File(srcFilePath);
  File[] files;
  if (root.exists() && root.listFiles() != null) {
    files = root.listFiles();
    for (File file: files) {
      File oldFile = new File(srcFilePath+file.getName());
      File newFile = new File(destFilePath+file.getName());
      DataInputStream dis = null;
      DataOutputStream dos = null;
      try {
        dos = new DataOutputStream(new FileOutputStream(newFile));
        dis = new DataInputStream(new FileInputStream(oldFile));
      } catch (FileNotFoundException e) {
        e.printStackTrace();
      }
      
      int temp;
      try {
        while ((temp = dis.read()) != -1) {
          dos.write(temp);
        }
        dis.close();
        dos.close();
      }catch (IOException e) {
        e.printStackTrace();
      }
    }
  }
}
/**
 * 遍歷刪除文件夾下所有文件
 * @param filePath
 */
public static void traversalFile(String filePath) {
  File file = new File(filePath);
  if (file.exists()) {
    File[] files = file.listFiles();
    for (File f: files) {
      if (f.isDirectory()) {
        traversalFile(f.getAbsolutePath());
      } else {
        f.delete();
      }
    }
    file.delete();
  }
}
/**
 * 刪除指定的File
 * @param filePath
 */
public static void deleteFile(String filePath) {
  File patFile = new File(filePath);
  if (patFile.exists()) {
    patFile.delete();
  }
}

當客戶端下載解析后,圖片的增量更新就搞定了,這樣我們的更新包就小了很多。缺點也很明顯,每次更新RN版本的時候,都要修改RN的源碼

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,621評論 2 380