iOS啟動優化之二進制重排

一、二進制重排介紹

1、App啟動

進程如果能直接訪問物理內存無疑是很不安全的,所以操作系統在物理內存的上又建立了一層虛擬內存。蘋果在這個基礎上還有 ASLR(Address Space Layout Randomization) 技術的保護,不過不是這次的重點。

iOS系統中虛擬內存到物理內存的映射都是以頁為最小單位的。當進程訪問一個虛擬內存Page而對應的物理內存卻不存在時,就會出現Page Fault缺頁中斷,然后加載這一頁。雖然本身這個處理速度是很快的,但是在一個App的啟動過程中可能出現上千(甚至更多)次Page Fault,這個時間積累起來會比較明顯了

另外,還有兩個重要的概念:冷啟動熱啟動。可能有些同學認為殺掉再重啟App就是冷啟動了,其實是不對的。

  • 冷啟動:程序完全退出,之間加載的分頁數據被其他進程所使用覆蓋之后,或者重啟設備、第一次安裝,才算是冷啟動。
  • 熱啟動:程序殺掉之后,馬上又重新啟動。這個時候相應的物理內存中仍然保留之前加載過的分頁數據,可以進行重用,不需要全部重新加載。所以熱啟動的速度比較快。

2、二進制重排原理

編譯器在生成二進制代碼的時候,默認按照鏈接的Object File(.o)順序寫文件,按照Object File內部的函數順序寫函數。

靜態庫文件.a就是一組.o文件的ar包,可以用ar -t查看.a包含的所有.o。

簡化問題:假設我們只有兩個page:page1/page2,其中綠色的method1和method5啟動時候需要調用,為了執行對應的代碼,系統必須進行兩個Page Fault。

但如果我們把method1和method5排布到一起,那么只需要一個Page Fault即可,這就是二進制文件重排的核心原理。

圖1:二進制重排原理.png

實際項目中的做法是將啟動時需要調用的函數放到一起 ( 比如 前10頁中 ) 以盡可能減少 page fault , 達到優化目的 . 而這個做法就叫做 : 二進制重排 。

注意:在iOS生產環境的app,在發生Page Fault進行重新加載時,iOS系統還會對其做一次簽名驗證,因此 iOS 生產環境的 Page Fault 比Debug環境下所產生的耗時更多

二、重排的order文件

1、文件順序

Build PhasesCompile Sources 列表順序決定了文件執行的順序(可以調整)。如果不進行重排,文件的順序決定了方法、函數的執行順序。我們在 ViewControllerAppDelegate 中加入以下代碼:

+ (void)load {
    NSLog(@"%s", __FUNCTION__);
}

我們調整 Compile Sources 中這兩個類的順序,然后分別執行對比??梢钥吹剑S著 Compile Sources 中的文件順序的修改,+load 方法的執行順序也發生了改變。

圖2:文件執行順序.png

2、符號表順序

Link Map 是iOS編譯過程的中間產物,記錄了二進制文件的布局,需要在Xcode的Build Settings 里開啟Write Link Map File,Link Map主要包含三部分:

  • Object Files 生成二進制用到的link單元的路徑和文件編號
  • Sections 記錄Mach-O每個Segment/section的地址范圍
  • Symbols 按順序記錄每個符號的地址范圍
1)Build Settings中修改Write Link Map FileYES
2)查找Link Map符號表txt文件

編譯后會生成一個Link Map符號表txt文件,選擇Product中的App,在Finder中打開,選擇Intermediates.noindex文件夾,找到LinkMap文件,這里是ZJHBinaryLaunchDemo-LinkMap-normal-x86_64.txt。。詳細路徑請看下圖。

圖3:查找符號表文件.png
3)查看Link Map符號表txt文件

打開文件之后來到第一部分的最后。我們可以看到這個順序和我們Compile Sources中的順序是一致的。接下來的部分:

圖4:查看符號表文件.png

可以看到,整體的順序和Compile Sources的中的順序是一樣的,并且方法是按照文件中方法的順序進行鏈接的。ViewController中的方法添加完后,才是AppDelegate中的方法,以此類推。

  • Address? 表示文件中方法的地址。
  • Size 表示方法的大小。
  • File 表示在第幾個文件中。
  • Name 表示方法名。

3、導入order文件

ld是Xcode使用的鏈接器,有一個參數order_file,我們可以通過在Build Settings -> Order File配置一個后綴為order的文件路徑.在這個order文件中,將所需要的符號按照順序寫在里面,在項目編譯時,會按照這個文件的順序進行加載,以此來達到我們的優化

來到工程根目錄 , 新建一個文件 touch ZJHBinaryLaunchDemo.order . 隨便挑選幾個啟動時就需要加載的方法 , 例如我這里選了以下幾個 .

+[AppDelegate load]
+[ViewController load]
_main
-[ViewController someMethod]

然后在Build Settings中找到Order File,填入./ZJHBinaryLaunchDemo.order。然后重新比納音,再次查看生成符號表txt文件。

圖5:導入order文件.png

可以看到Link Map中的最上面幾個方法和我們在ZJHBinaryLaunchDemo.order文件中設置的方法順序一致!

Xcode的連接器ld還忽略掉了不存在的方法 -[ViewController someMethod]。如果提供了link選項 -order_file_statistics,會以warning的形式把這些沒找到的符號打印在日志里。

注意:有部分同學可能配置完運行會發現報錯說can't open 這個 order file . 是因為文件格式的問題 . 不用使用 mac 自帶的文本編輯 . 使用命令工具 touch 創建即可 .

三、檢測啟動時方法

要真正的實現二進制重排,我們需要拿到啟動時所有用到的方法、函數等符號,并保存其順序,然后寫入order文件,實現二進制重排。這里我們使用Clang插樁的方式

1、Clang插樁原理

其實就是一個代碼覆蓋工具,更多信息可以查看官網。

1)首先 , 添加編譯設置

Build SettingsOther C Flags添加配置

-fsanitize-coverage=trace-pc-guard

編譯的話會報以下錯誤,意思是找不到這兩個函數

Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
Undefined symbol: ___sanitizer_cov_trace_pc_guard
2)添加 hook 代碼

我們把面的代碼,添加到 ViewController.m

#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>

void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
  static uint64_t N;  // Counter for the guards.
  if (start == stop || *start) return;  // Initialize only once.
  printf("INIT: %p %p\n", start, stop);
  for (uint32_t *x = start; x < stop; x++)
    *x = ++N;  // Guards should start from 1.
}

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;  // Duplicate the guard check.
//  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
//  __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

3)運行工程 , 查看打印
INIT: 0x1024153e0 0x102415420
guard: 0x1024153f8 7 PC 
guard: 0x1024153ec 4 PC ?
guard: 0x102415414 e PC 
guard: 0x102415418 f PC ?
guard: 0x102415414 e PC 
guard: 0x102415414 e PC 
guard: 0x1024153fc 8 PC $
guard: 0x102415414 e PC 
guard: 0x102415414 e PC ?
guard: 0x102415414 e PC \300\202\3605?

代碼命名 INIT 后面打印的兩個指針地址叫 startstop . 那么我們通過 lldb 來查看下從 startstop 這個內存地址里面所存儲的到底是啥 .

4)驗證 startstop 內存地址存儲值

viewDidLoad方法中添加斷點,執行項目。在lldb分別輸入 x start地址x stop地址-0x4,讀取內存地址。 x stop地址-0x4 是結束位置,按顯示是4位的,所以向前移動4位,打印出來的應該就是最后一位。

圖6:驗證start到stop內存地址存儲值.png

發現存儲的是從 116(0x10) 這個序號 ,我們再新增兩個方法,再次運行查看:

INIT: 0x102fb93f0 0x102fb9438

(lldb) x 0x102fb9438-0x4
0x102fb9434: 12 00 00 00 f0 92 0c 03 01 00 00 00 00 00 00 00  ................
0x102fb9444: 00 00 00 00 5f 33 fb 02 01 00 00 00 00 00 00 00  ...._3..........
(lldb) 

發現從 0x10 變成了 0x12 . 也就是說存儲的 116 這個序號變成了 118 。那么我們得到一個猜想 , 這個內存區間保存的就是工程所有符號的個數 。

5)驗證guard調用次數

touchesBegan 方法中,打印語句,點擊屏幕。每點擊一次就會調用一次 guard :。而且 guard :是在前面調用的。

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"touchesBegan");
}

// 控制臺的輸出
guard: 0x1006053fc 4 PC ?
2021-04-21 13:29:51.936925+0800 ZJHBinaryLaunchDemo[13644:5077278] touchesBegan

touchesBegan 方法中,執行調用test1方法,會發現 guard :調用了兩次

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"touchesBegan");
    [self test1];
}

- (void)test1 {
    NSLog(@"test1");
}

// 控制臺的輸出
guard: 0x102d893f8 3 PC ?
2021-04-21 13:31:57.152720+0800 ZJHBinaryLaunchDemo[13646:5077923] touchesBegan
guard: 0x102d893fc 4 PC d?\330?\201\226P\314\370\223\330??
2021-04-21 13:31:57.152915+0800 ZJHBinaryLaunchDemo[13646:5077923] test1

由此,發現我們實際調用幾個方法 , 就會打印幾次 guard :。

6)查看匯編代碼驗證

我們在 touchesBegan:touches withEvent: 開頭設置一個斷點,并開啟匯編顯示(菜單欄Debug → Debug Workflow → Always Show Disassembly)。

圖7:查看匯編代碼驗證.png

通過匯編我們發現 , 在每個函數調用的第一句實際代碼 ( 棧平衡與寄存器數據準備除外 ) , 被添加進去了一個 bl 調用到 __sanitizer_cov_trace_pc_guard 這個函數中來 。而實際上這也是靜態插樁的原理和名稱由來 。

靜態插樁總結:靜態插樁實際上是在編譯期就在每一個函數內部二進制源數據添加 hook 代碼 ( 我們添加的 __sanitizer_cov_trace_pc_guard 函數 ) 來實現全局的方法 hook 的效果 .

2、獲取所有函數符號

1)獲取原函數地址

我們在 __sanitizer_cov_trace_pc_guard 函數中的這一句代碼 :

void *PC = __builtin_return_address(0); 

它的作用其實就是去讀取 x30 中所存儲的要返回時下一條指令的地址 . 所以他名稱叫做 __builtin_return_address . 換句話說 , 這個地址就是我當前這個函數執行完畢后 , 要返回到哪里去 。也就是說 , 我們現在可以在 __sanitizer_cov_trace_pc_guard 這個函數中 , 通過 __builtin_return_address 數拿到原函數調用 __sanitizer_cov_trace_pc_guard 這句匯編代碼的下一條指令的地址 。及上圖中,拿到-[ViewController touchesBegan:withEvent:] 地址

2)根據內存地址獲取函數名稱

拿到了函數內部一行代碼的地址 , 如何獲取函數名稱呢 ? 熟悉安全攻防 , 逆向的同學可能會清楚 . 我們為了防止某些特定的方法被別人使用 fishhook hook 掉 , 會利用 dlopen 打開動態庫 , 拿到一個句柄 , 進而拿到函數的內存地址直接調用 .是不是跟我們這個流程有點相似 , 只是我們好像是反過來的 . 其實反過來也是可以的 .

dlopen 相同 , 在 dlfcn.h 中有一個方法如下 :

typedef struct dl_info {
        const char      *dli_fname;     /* 所在文件 */
        void            *dli_fbase;     /* 文件地址 */
        const char      *dli_sname;     /* 符號名稱 */
        void            *dli_saddr;     /* 函數起始地址 */
} Dl_info;

//這個函數能通過函數內部地址找到函數符號
int dladdr(const void *, Dl_info *);

緊接著我們來實驗一下 , 先導入頭文件#import <dlfcn.h> , 然后修改代碼如下 :

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;  // Duplicate the guard check.

    void *PC = __builtin_return_address(0);
    Dl_info info;
    dladdr(PC, &info);

    printf("fname=%s \nfbase=%p \nsname=%s\nsaddr=%p \n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);

    char PcDescr[1024];
    printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

查看打印結果,可以看到我們要找的符號了 :

圖8:根據內存地址獲取函數名稱.png

3、可能遇到的問題

1)多線程問題

項目各個方法肯定有可能會在不同的函數執行 , 因此 __sanitizer_cov_trace_pc_guard 這個函數也有可能受多線程影響 , 所以你當然不可能簡簡單單用一個數組來接收所有的符號就搞定了??紤]到這個方法會來特別多次 , 使用鎖會影響性能 , 這里使用蘋果底層的原子隊列 ( 底層實際上是個棧結構 , 利用隊列結構 + 原子性來保證順序 ) 來實現 .

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    //遍歷出隊
    while (true) {
        //offsetof 就是針對某個結構體找到某個屬性相對這個結構體的偏移量
        SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
        if (node == NULL) break;
        Dl_info info;
        dladdr(node->pc, &info);

        printf("%s \n",info.dli_sname);
    }
}
//原子隊列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定義符號結構體
typedef struct{
    void * pc;
    void * next;
}SymbolNode;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;  // Duplicate the guard check.
    void *PC = __builtin_return_address(0);
    SymbolNode * node = malloc(sizeof(SymbolNode));
    *node = (SymbolNode){PC,NULL};

    //入隊
    // offsetof 用在這里是為了入隊添加下一個節點找到 前一個節點next指針的位置
    OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}

但此方法會導致死循環的問題

2)死循環問題

通過匯編會查看到 一個帶有 while 循環的方法 , 會被靜態加入多次 __sanitizer_cov_trace_pc_guard 調用 , 導致死循環.

Other C Flags 修改為如下 :

-fsanitize-coverage=func,trace-pc-guard

代表進針對 func 進行 hook . 再次運行就可以了。

3) load 方法不打印問題

load 方法時 , __sanitizer_cov_trace_pc_guard 函數的參數 guard 是 0。上述打印并沒有發現 load .

解決 : 屏蔽掉 __sanitizer_cov_trace_pc_guard 函數中的

if (!*guard) return;

效果如下

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//    if (!*guard) return;  // Duplicate the guard check.
    void *PC = __builtin_return_address(0);
    SymbolNode * node = malloc(sizeof(SymbolNode));
    *node = (SymbolNode){PC,NULL};

    //入隊
    // offsetof 用在這里是為了入隊添加下一個節點找到 前一個節點next指針的位置
    OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}

// 打印結果
INIT: 0x104d8d400 0x104d8d444
-[ViewController touchesBegan:withEvent:] 
-[SceneDelegate sceneDidBecomeActive:] 
-[SceneDelegate sceneWillEnterForeground:] 
-[ViewController viewDidLoad] 
-[SceneDelegate window] 
-[SceneDelegate window] 
-[SceneDelegate window] 
-[SceneDelegate scene:willConnectToSession:options:] 
-[SceneDelegate window] 
-[SceneDelegate window] 
-[SceneDelegate setWindow:] 
-[SceneDelegate window] 
-[AppDelegate application:didFinishLaunchingWithOptions:] 
main 
+[AppDelegate load] 
+[ViewController load] 

這樣的話,load 方法就有了。這里也為我們提供了一點啟示:如果我們希望從某個函數之后/之前開始優化 , 通過一個全局靜態變量 , 在特定的時機修改其值 , 在 __sanitizer_cov_trace_pc_guard 這個函數中做好對應的處理即可 .

4、符號信息寫入文件

完整代碼如下 :

#import "ViewController.h"
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
@interface ViewController ()
@end

@implementation ViewController
+ (void)load{

}
- (void)viewDidLoad {
    [super viewDidLoad];
    testCFunc();
    [self testOCFunc];
}
- (void)testOCFunc{
    NSLog(@"oc函數");
}
void testCFunc(){
    LBBlock();
}
void(^LBBlock)(void) = ^(void){
    NSLog(@"block");
};

void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
                                         uint32_t *stop) {
    static uint64_t N;  // Counter for the guards.
    if (start == stop || *start) return;  // Initialize only once.
    printf("INIT: %p %p\n", start, stop);
    for (uint32_t *x = start; x < stop; x++)
        *x = ++N;  // Guards should start from 1.
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
    while (true) {
        //offsetof 就是針對某個結構體找到某個屬性相對這個結構體的偏移量
        SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
        if (node == NULL) break;
        Dl_info info;
        dladdr(node->pc, &info);

        NSString * name = @(info.dli_sname);

        // 添加 _
        BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
        NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];

        //去重
        if (![symbolNames containsObject:symbolName]) {
            [symbolNames addObject:symbolName];
        }
    }

    //取反
    NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects];
    NSLog(@"%@",symbolAry);

    //將結果寫入到文件
    NSString * funcString = [symbolAry componentsJoinedByString:@"\n"];
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"lb.order"];
    NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
    BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    if (result) {
        NSLog(@"%@",filePath);
    }else{
        NSLog(@"文件寫入出錯");
    }

}
//原子隊列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
//定義符號結構體
typedef struct{
    void * pc;
    void * next;
}SymbolNode;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    //if (!*guard) return;  // Duplicate the guard check.

    void *PC = __builtin_return_address(0);

    SymbolNode * node = malloc(sizeof(SymbolNode));
    *node = (SymbolNode){PC,NULL};

    //入隊
    // offsetof 用在這里是為了入隊添加下一個節點找到 前一個節點next指針的位置
    OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
@end

文件寫入到了 tmp 路徑下 , 運行 , 打開手機下載查看 :

圖9:符號信息寫入文件.png

5、swift 工程 / 混編工程問題

通過如上方式適合純 OC 工程獲取符號方式 .由于 swift 的編譯器前端是自己的 swift 編譯前端程序 , 因此配置稍有不同 .

搜索 Other Swift Flags , 添加兩條配置即可 :

  • -sanitize-coverage=func
  • -sanitize=undefined

swift 類通過上述方法同樣可以獲取符號 .

四、驗證

1、打印啟動時間

在系統執行應用程序的main函數并調用應用程序委托函數(applicationWillFinishLaunching)之前,會發生很多事情。我們可以通過添加環境變量可以打印處APP的啟動分析(Edit scheme -> Run -> Argument).
DYLD_PRINT_STATISTICS設置為1(dyld_print_statistics)。如果需要更詳細的信息,那就將DYLD_PRINT_STATISTICS_DETAILS設置為1

運行一下,對比控制臺的輸出(因筆者是在demo驗證,時間優化的效果不明顯,這里就不做對比展示了):

Total pre-main time:  97.73 milliseconds (100.0%)
         dylib loading time:  28.02 milliseconds (28.6%)
        rebase/binding time:  21.70 milliseconds (22.2%)
            ObjC setup time:   5.16 milliseconds (5.2%)
           initializer time:  42.85 milliseconds (43.8%)
           slowest intializers :
             libSystem.B.dylib :   6.26 milliseconds (6.4%)
   libBacktraceRecording.dylib :   9.88 milliseconds (10.1%)
    libMainThreadChecker.dylib :  22.10 milliseconds (22.6%)
           ZJHBinaryLaunchDemo :   2.81 milliseconds (2.8%)

2、System Trace

日常開發中性能分析是用最多的工具無疑是Time Profiler,但Time Profiler是基于采樣的,并且只能統計線程實際在運行的時間,而發生Page Fault的時候線程是被blocked,所以我們需要用一個不常用但功能卻很強大的工具:System Trace。

選中主線程,在VM Activity中的File Backed Page In次數就是Page Fault次數,并且雙擊還能按時序看到引起Page Fault的堆棧:

圖10:System Trace.jpeg

五、CocoaPods相關

對于 cocoapod 工程引入的庫 , 由于針對不同的 target . 那么我們在主程序中的 target 添加的編譯設置 Write Link Map File , -fsanitize-coverage=func,trace-pc-guard 以及 order file 等設置肯定是不會生效的 . 解決方法就是針對需要的 target 去做對應的設置即可 .

對于直接手動導入到工程里的 sdk , 不管是 靜態庫 .a 還是 動態庫 , 默認主工程的設置就可以了 , 是可以拿到符號的 .

更多CocoaPods相關問題,可參考這篇文章:https://juejin.cn/post/6844904192193085448


參考鏈接:

iOS 啟動優化之Clang插樁實現二進制重排
我是如何讓微博綠洲的啟動速度提升30%的
懶人版二進制重排
抖音研發實踐:基于二進制文件重排的解決方案 APP啟動速度提升超15%
懶人版二進制重排
手淘架構組最新實踐 | iOS基于靜態庫插樁的?進制重排啟動優化

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

推薦閱讀更多精彩內容