AOP之AspectJ在Android中的應用

1 前言

1.1 什么是AOP,與OOP的區別

  • OOP:即ObjectOriented Programming,面向對象編程。功能都被劃分到一個一個的模塊里邊,每個模塊專心干自己的事情,模塊之間通過設計好的接口交互。
  • AOP:即Aspect Oriented Programming,面向切面編程。通過預編譯方式和運行期動態代理實現程序功能的統一維護的一種技術。
OOP AOP
面向目標 面向名詞領域 面向動詞領域
思想結構 縱向結構 橫向結構
注重方面 注重業務邏輯單元的劃分 偏重業務處理過程的某個步驟或階段

下圖:有三個模塊:登陸、轉賬、大文件上傳,現在需要加入性能檢測功能,統計這三個模塊每個方法耗時多少,OOP思想做法是設計一個性能檢測模塊,提供接口供這三個模塊調用。這樣每個模塊都要調用性能檢測模塊的接口,如果接口有改動,需要在這三個模塊中每次調用的地方修改,這樣做的弊端有:代碼冗余,邏輯不清晰,重構不方便,違背單一原則。運用AOP的思想做法是:在這些獨立的模塊間,在特定的切入點進行hook,將共同的邏輯添加到模塊中而不影響原有模塊的獨立性。如下圖OOP實現轉AOP實現,在不同的模塊中加入性能檢測功能,并不影響原有的架構。


OOP實現轉AOP實現.png

1.2 Android AOP主流框架

名稱 描述
Xposed ROOT社區著名開源項目,需要root權限(運行時)
Dexposed 阿里AOP框架,改造Xposed,只支持Android2.3 - 4.4(運行時)
APT 注解處理器,通過注解生成源代碼,代表框架:DataBinding,Dagger2, ButterKnife, EventBus3 、DBFlow、AndroidAnnotation
AspectJ AspectJ定義了AOP語法,所以它有一個專門的編譯器用來生成遵守Java字節編碼規范的Class文件,在編譯期注入代碼。代表框架:Hugo(Jake Wharton)
Javassist、ASM 執行字節碼操作的庫。它可以在一個已經編譯好的類中添加新的方法,或者是修改已有的方法,可以繞過編譯,直接操作字節碼,從而實現代碼注入。代表框架:熱修復框架HotFix 、InstantRun
APT,AspectJ,Javassist對應的編譯時期.jpg

2 AspectJ

2.1 環境配置

aspectJ常規配置

在app/build.gradle下配置:

import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main
import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'org.aspectj:aspectjtools:1.8.9'
        classpath 'org.aspectj:aspectjweaver:1.8.9'
    }
}

variants.all { variant ->
    if (!variant.buildType.isDebuggable()) {
        log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
        return;
    }

    JavaCompile javaCompile = variant.javaCompile
    javaCompile.doLast {
        String[] args = ["-showWeaveInfo",
                         "-1.8",
                         "-inpath", javaCompile.destinationDir.toString(),
                         "-aspectpath", javaCompile.classpath.asPath,
                         "-d", javaCompile.destinationDir.toString(),
                         "-classpath", javaCompile.classpath.asPath,
                         "-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
        log.debug "ajc args: " + Arrays.toString(args)

        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, handler);
        for (IMessage message : handler.getMessages(null, true)) {
            switch (message.getKind()) {
                case IMessage.ABORT:
                case IMessage.ERROR:
                case IMessage.FAIL:
                    log.error message.message, message.thrown
                    break;
                case IMessage.WARNING:
                    log.warn message.message, message.thrown
                    break;
                case IMessage.INFO:
                    log.info message.message, message.thrown
                    break;
                case IMessage.DEBUG:
                    log.debug message.message, message.thrown
                    break;
            }
        }
    }
}

dependencies {
    compile files('libs/aspectjrt.jar')  //將aspectjrt.jar包拷貝至app/libs目錄下
}

aspectjx插件配置

在根build.gradle下配置:

 dependencies {
        classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:1.0.10'
 }

在app/build.gradle下配置:

apply plugin: 'android-aspectjx'
dependencies {
    compile 'org.aspectj:aspectjrt:1.8.9'
}

aspectjx默認會遍歷項目編譯后所有的.class文件和依賴的第三方庫去查找符合織入條件的切點,為了提升編譯效率,可以加入過濾條件指定遍歷某些庫或者不遍歷某些庫。

aspectjx {
    //織入遍歷符合條件的庫
    includeJarFilter 'universal-image-loader', 'AspectJX-Demo/library'
    //排除包含‘universal-image-loader’的庫
    excludeJarFilter 'universal-image-loader'
}

配置注意

  1. 黑名單必須放在app/build.gralde下才生效
  2. app下的代碼會自動加入白名單
  3. gradle插件版本目前不支持3.0以上
  4. 只支持AspectJ annotation的方式
  5. 建議配置白名單,否則編譯時會遇到沖突

兩種配置區別

AspectJ常規配置不支持AAR或者JAR切入的,只會對編譯的代碼進行織入,AspectJX插件配置支持AAR, JAR及Kotlin的應用。這里需要注意的,在AspectJ常規配置中有這樣的代碼:"-inpath", javaCompile.destinationDir.toString(),代表只對源文件進行織入。在查看Aspectjx源碼時,發現在“-inputs”配置加入了.jar文件,使得class類可以被織入代碼。這么理解來看,AspectJ也是支持對class文件的織入的,只是需要對它進行相關的配置,而配置比較繁瑣,所以誕生了AspectJx等插件。

aspectjx github鏈接點此

2.2 語法

通常AspectJ需要編寫aj文件,然后把AOP代碼放到aj后綴名文件中,如下:

public pointcut  testAll(): call(public  *  *.println(..)) && !within(TestAspect) ;  

在Android開發中,建議不要使用aj文件。因為aj文件只有AspectJ編譯器才認識,而Android編譯器不認識這種文件。所以當更新了aj文件后,編譯器認為源碼沒有發生變化,不會編譯它。所以AspectJ提供了一種基于注解的方法,如下:

@Pointcut(“call(public  *  *.println(..)) && !within(TestAspect)")//方法切入點
public void testAll() { }

Join Points

Join Points 就是程序運行時的一些執行點,例如:我要打印所有Activity的onCreate方法,onCreate方法被調用就是一個Join Points。除了方法被調用,還有很多,例如方法內部“讀、寫”變量,異常處理等,如下表:

Join Point 說明 Pointcuts語法
Method call 方法被調用 call(MethodPattern)
Method execution 方法執行 execution(MethodPattern)
Constructor call 構造函數被調用 call(ConstructorPattern)
Constructor execution 構造函數執行 execution(ConstructorPattern)
Field get 讀取屬性 get(FieldPattern)
Field set 寫入屬性 set(FieldPattern)
Pre-initialization 與構造函數有關,很少用到 preinitialization(ConstructorPattern)
Initialization 與構造函數有關,很少用到 initialization(ConstructorPattern)
Static initialization static 塊初始化 staticinitialization(TypePattern)
Handler 異常處理 handler(TypePattern)
Advice execution 所有 Advice 執行 adviceexcution()
call和execution區別.png

Pointcuts

上表中,同一個函數,還分為call類型和execution類型的JPoint,如何選擇自己想要的JPoint呢,這就是Pointcuts的功能:提供一種方法使得開發者能夠選擇自己感興趣的JoinPoints。例如:我要打印所有Activity的onCreate方法,Pointcuts需要篩選的就是所有Activity的onCreate方法,而不是任意類的onCreate方法。除了上表與Join Point 對應的選擇外,Pointcuts 還有其他選擇方法:

Pointcuts 語法 說明 示例
within(TypePattern) TypePattern標示package或者類 TypePatter可以使用通配符 表示某個package或者類中的所有JPoint。比如within(Test):Test類中所有JPoint
withincode(Constructor Signature/Method Signature) 表示某個構造函數或其他函數執行過程中涉及到的JPoint 比如 withinCode(* TestDerived.testMethod(..)) 表示testMethod涉及的JPoint。withinCode( *.Test.new(..))表示Test構造函數涉及的JPoint
cflow(pointcuts) cflow是call flow的意思,cflow的條件是一個pointcut 比如cflow(call TestDerived.testMethod):表示調用TestDerived.testMethod函數時所包含的JPoint,包括testMethod的call這個JPoint本身
cflowbelow(Pointcut) cflow是call flow的意思 比如cflowblow(call TestDerived.testMethod):表示調用TestDerived.testMethod函數時所包含的JPoint,不包括testMethod的call這個JPoint本身
this(Type) Join Point 所屬的 this 對象是否 instanceOf Type 或者 Id 的類型 JPoint是代碼段(不論是函數,異常處理,static block),從語法上說,它都屬于一個類。如果這個類的類型是Type標示的類型,則和它相關的JPoint將全部被選中。圖2示例的testMethod是TestDerived類。所以this(TestDerived)將會選中這個testMethod JPoint
target(Type) JPoint的target對象是Type類型 和this相對的是target。不過target一般用在call的情況。call一個函數,這個函數可能定義在其他類。比如testMethod是TestDerived類定義的。那么target(TestDerived)就會搜索到調用testMethod的地方。但是不包括testMethod的execution JPoint
args(TypeSignature) 用來對JPoint的參數進行條件搜索的 比如args(int,..),表示第一個參數是int,后面參數個數和類型不限的JPoint。

Pointcut 表達式還可以 !、&&、|| 來組合,語義和java一樣。上面 Pointcuts 的語法中涉及到一些 Pattern,下面是這些 Pattern 的規則,[]里的內容是可選的:

Pattern 規則
MethodPattern [!] [@Annotation] [public,protected,private] [static] [final] 返回值類型 [類名.]方法名(參數類型列表) [throws 異常類型]
ConstructorPattern [!] [@Annotation] [public,protected,private] [final] [類名.]new(參數類型列表) [throws 異常類型]
FieldPattern [!] [@Annotation] [public,protected,private] [static] [final] 屬性類型 [類名.]屬性名
TypePattern 其他 Pattern 涉及到的類型規則也是一樣,可以使用 ‘!’、’‘、’..’、’+’,’!’ 表示取反,’‘ 匹配除 . 外的所有字符串,’*’ 單獨使用事表示匹配任意類型,’..’ 匹配任意字符串,’..’ 單獨使用時表示匹配任意長度任意類型,’+’ 匹配其自身及子類,還有一個 ‘…’表示不定個數。也可以使用 &&、|| 操作符

下面主要介紹下上表中的MethodPattern
MethodPattern對應的一個完整的表達式為:@注解 訪問權限 返回值的類型 包名.函數名(參數)

  • @注解和訪問權限(public/private/protect,以及static/final)屬于可選項。如果不設置它們,則默認都會選擇。以訪問權限為例,如果沒有設置訪問權限作為條件,那么public,private,protect及static、final的函數都會進行搜索。
  • 返回值類型就是普通的函數的返回值類型。如果不限定類型的話,就用*通配符表示
  • 包名.函數名用于查找匹配的函數。可以使用通配符,包括和..以及+號。其中號用于匹配除.號之外的任意字符,而..則表示任意子package,+號表示子類。 比如:
1) java.*.Date:可以表示java.sql.Date,也可以表示java.util.Date  
2) Test*:可以表示TestBase,也可以表示TestDervied  
3) java..*:表示java任意子類
4) java..*Model+:表示Java任意package中名字以Model結尾的子類,比如TabelModel,TreeModel 等  
  • 最后來看函數的參數。參數匹配比較簡單,主要是參數類型,比如:
1) (int, char):表示參數只有兩個,并且第一個參數類型是int,第二個參數類型是char 
2) (String, ..):表示至少有一個參數。并且第一個參數類型是String,后面參數類型不限.
3) ..代表任意參數個數和類型  
4) (Object ...):表示不定個數的參數,且類型都是Object,這里的...不是通配符,而是Java中代表不定參數的意思  

Pointcuts 示例

以下示例表示在aspectjx插件下,相同包是指同一個aar/jar包,AspectJ常規配置下不同包不能執行“execution”織入

execution
  1. execution(* com.howtodoinjava.EmployeeManager.*( .. ))
    匹配EmployeeManger接口中所有的方法
  2. execution(* EmployeeManager.*( .. ))
    當切面方法和EmployeeManager接口在相同的包下時,匹配EmployeeManger接口中所有的方法
  3. execution(public * EmployeeManager.*(..))
    當切面方法和EmployeeManager接口在相同的包下時,匹配EmployeeManager接口的所有public方法
  4. execution(public EmployeeDTO EmployeeManager.*(..))
    匹配EmployeeManager接口中權限為public并返回類型為EmployeeDTO的所有方法。
  5. execution(public EmployeeDTO EmployeeManager.*(EmployeeDTO, ..))
    匹配EmployeeManager接口中權限為public并返回類型為EmployeeDTO,第一個參數為EmployeeDTO類型的所有方法。
  6. execution(public EmployeeDTO EmployeeManager.*(EmployeeDTO, Integer))
    匹配EmployeeManager接口中權限為public、返回類型為EmployeeDTO,參數明確定義為EmployeeDTO,Integer的所有方法。
  7. "execution(@com.xyz.service.BehaviorTrace * *(..))"
    匹配注解為"@com.xyz.service.BehaviorTrace",返回值為任意類型,任意包名下的任意方法。
within

任意連接點:包括類/對象初始化塊,field,方法,構造器

  1. within(com.xyz.service.*)
    com.xyz.service包下任意連接點
  2. within(com.xyz.service..*)
    com.xyz.service包或子包下任意連接點
  3. within(TestAspect)
    TestAspect類下的任意連接點
  4. within(@com.xyz.service.BehavioClass *)
    持有com.xyz.service.BehavioClass注解的任意連接點
withincode

假設方法functionA, functionB都調用了dummy,但只想在functionB調用dummy時織入代碼。

public void functionA() { dummy() }
public void functionB() { dummy() } 
public void dummy() {}  // 只在functionB調用的時候織入代碼
@Aspect  // 加上@Aspect注解表示此類會被aspectj編譯器編譯,相關的Pointcut才會被織入
public class MethodTracer {
  // withincode: 在functionB方法內
  @Pointcut("withincode(void org.sdet.aspectj.MainActivity.functionB(..))")
  public void invokeFunctionB() {}

  // call: 調用dummy方法
  @Pointcut("call(void org.sdet.aspectj.MainActivity.dummy(..))")
  public void invokeDummy() {}

  // 在functionB內調用dummy方法
  @Pointcut("invokeDummy() && invokeFunctionB()")
  public void invokeDummyInsideFunctionB() {}
  
  // 在functionB方法內,調用dummy方法之前invoke下面代碼(目前僅打印xxx)
  @Before("invokeDummyInsideFunctionB()")
  public void beforeInvokeDummyInsideFunctionB(JoinPoint joinPoint) {
    System.out.printf("Before.InvokeDummyInsideFunctionB.advice() called on '%s'", joinPoint);
  }
}

Advice

之前介紹的是如何找到切點,現在介紹的Advice就是告訴我們如何切,換個說法就是告訴我們要插入的代碼以何種方式插入,比如說有以下幾種:

名稱 描述
Before 在方法執行之前執行要插入的代碼
After 在方法執行之后執行要插入的代碼
AfterReturning 在方法執行后,返回一個結果再執行,如果沒結果,用此修辭符修辭是不會執行的
AfterThrowing 在方法執行過程中拋出異常后執行,也就是方法執行過程中,如果拋出異常后,才會執行此切面方法。
Around 在方法執行前后和拋出異常時執行(前面幾種通知的綜合)
Before、After示例

Before和After原理和用法一樣,只是一個在方法前插入代碼,一個在方法后面插入代碼,在此只介紹Before。例如:在"com.luyao.aop.aspectj.AspectJActivity"執行onCreate里的代碼之前打印"hello world"

package com.luyao.aop.aspectj;
public class AspectJActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    }
}
@Before("execution(* com.luyao.aop.aspectj.AspectJActivity.on*(android.os.Bundle))")
public void onActivityMethodBefore(JoinPoint joinPoint) throws Throwable {
    Log.e("luy", "hello world");
}}

查看反編譯后的代碼:

Before示例
AfterReturning示例

在"com.luyao.aop.aspectj.AspectJActivity"執行getHeight()方法返回高度后打印這個高度值。

package com.luyao.aop.aspectj;
public class AspectJActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getHeight();
    }
    public int getHeight() {
            return 1280;
    }
}
@AfterReturning(pointcut = "call(* com.luyao.aop.aspectj.AspectJActivity.getHeight())", returning = "height")
public void getHeight(int height) {  // height必須和上面"height"一樣
    Log.e("luy", "height:" + height);
}

反編譯后的代碼:

AfterReturning示例
AfterThrowing示例

如果我們經常需要收集拋出異常的方法信息,可以使用@AfterThrowing。比如我們要在任意類的任意方法拋出異常時,打印這個異常信息:

public class AspectJActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        divideZero();
    }
    public void divideZero() {
        int i = 2 / 0;
    }
}
@AfterThrowing(pointcut = "call(* *..*(..))", throwing = "throwable")  // "throwable"必須和下面參數名稱一樣
public void anyFuncThrows(Throwable throwable) {
    Log.e("luy", "throwable--->" + throwable);   // throwable--->java.lang.ArithmeticException: divide by zero
}

反編譯后的代碼:

AfterThrowing示例

注意點:

  1. @AfterThrowing 不支持 Field -> get & set,一般用在 Method 和 Constructor
  2. 捕獲的是拋出異常的方法,即使這個方法的調用方已經處理了此異常。上面例子中即使divideZero()調用了try catch, 也能被anyFuncThrows織入。
Around 示例

例如我想在"com.luyao.aop.aspectj.AspectJActivity "執行setContentView方法前后打印當前系統時間:

package com.luyao.aop.aspectj;
public class AspectJActivity extends Activity {
    private static final String TAG = "luyao";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_aspect_j);
    }
}
@Around("call(* com.luyao.aop.aspectj.AspectJActivity.setContentView(..))")
public void invokeSetContentView(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
    Log.e("luy", "執行setContentView方法前:" + System.currentTimeMillis());
    proceedingJoinPoint.proceed();
    Log.e("luy", "執行setContentView方法后:" + System.currentTimeMillis());
}

反編譯后的代碼:

Around 示例

2.3 簡單使用案例

需求:如圖,假設有2個功能分別是"朋友圈"和"搖一搖",功能很簡單,點擊按鈕觸發睡眠和打印日志。統計這2個功能的耗時。

思路:一般思路是在調用這2個方法之前后分別獲取當前系統的時間戳,然后相減得到耗時,關鍵代碼如下:

// 搖一搖點擊事件處理
public void shake(View view) {
    long begin = SystemClock.currentThreadTimeMillis();

    Log.i(TAG, "進入搖一搖方法體");
    SystemClock.sleep(3000);

    long end = SystemClock.currentThreadTimeMillis();
    Log.i(TAG, "耗時:" + (end - begin));
}

// 朋友圈點擊事件處理
public void friend(View view) {
    long begin = System.currentThreadTimeMillis();

    Log.i(TAG, "進入朋友圈方法體");
    SystemClock.sleep(2000);

    long end = System.currentThreadTimeMillis();
    Log.i(TAG, "耗時:" + (end - begin));
}

上面這種處理方法對于功能點少還好處理,如果很多方法都需要統計,每個方法都這樣寫無疑加了很大的工作量,導致代碼閱讀邏輯不清晰,重構不方便,違背單一原則。如果使用 AspectJ,可以通過一行注解,解決所有需要統計耗時的方法。具體代碼如下:

編寫布局文件:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.luyao.aop.aspectj.AspectJActivity"
    android:orientation="vertical">

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:onClick="shake"
        android:text="搖一搖"
        android:textSize="20sp"/>

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:onClick="friend"
        android:textSize="20sp"
        android:text="朋友圈"/>

</LinearLayout>

定義注解
@Target(ElementType.METHOD) // 修飾的是方法
@Retention(RetentionPolicy.CLASS) // 編譯時注解
public @interface BehaviorTrace {
    String value(); // 功能點名稱
    int type(); // 唯一確定功能點的值
}

通過定義@BehaviorTrace 來給"搖一搖"和"朋友圈"方法添加注解

編寫 Aspect
@Aspect // 此處一定要定義,否則不會該類不會參與編譯
public class BehaviorAspect {

    @Pointcut("execution(@com.luyao.aop.aspectj.BehaviorTrace  * *(..))") // 定義切點
    public void annoBehavior() {
    }

    @Around("annoBehavior()") // 定義怎么切,也可以這么寫 @Around("execution(@com.luyao.aop.aspectj.BehaviorTrace  * *(..))")
    public void dealPoint(ProceedingJoinPoint point) throws Throwable {
        //方法執行前
        MethodSignature methodSignature = (MethodSignature) point.getSignature();
        BehaviorTrace behaviorTrace = methodSignature.getMethod().getAnnotation(BehaviorTrace.class); // 拿到注解
        long begin = System.currentTimeMillis();
        Log.i("luy", "拿到需要切的方法啦,執行前");

        point.proceed(); // 執行被切的方法

        //方法執行完成
        long end = System.currentTimeMillis();
        Log.i("luy", behaviorTrace.value() + "(" + behaviorTrace.type() + ")" + " 耗時:" +  (end - begin) + "ms");
    }

}
使用@BehaviorAspect
public class AspectJActivity extends Activity {
    private static final String TAG = "luy";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_aspect_j);
    }

    @BehaviorTrace(value = "搖一搖", type = 1)
    public void shake(View view) {
        Log.i(TAG, "進入搖一搖方法體");
        SystemClock.sleep(3000);
    }

    @BehaviorTrace(value = "朋友圈", type = 2)
    public void friend(View view) {
        Log.i(TAG, "進入朋友圈方法體");
        SystemClock.sleep(2000);
    }
}

編寫完畢,接下來測試,點擊搖一搖打印日志:

拿到需要切的方法啦,執行前
進入搖一搖方法體
搖一搖(1) 耗時:3000ms

點擊朋友圈打印日志:

拿到需要切的方法啦,執行前
進入朋友圈方法體
朋友圈(2) 耗時:2000ms

寫在最后

本文介紹了AOP的思想、AOP的幾種工具和AspectJ的基本用法。在實際開發項目中,當有需求時,了解AOP可以多一種思維方式去解決問題。同時,AspectJ織入代碼會增加編譯時間,使用時也需要考慮。

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

推薦閱讀更多精彩內容