MVVM与Controller瘦身实践

前言

MVC是一个做iOS开发都知道的设计模式,也是Apple官方推荐的设计模式。实际上,Cocoa Touch就是按照MVC来设计的。

这里,我们先不讲MVC是什么,我们先来谈谈软件设计的一些原则或者说理念。在开发App的时候,我们的基本目标有以下几点:

可靠性 - App的功能能够正常使用

健壮性 - 在用户非正常使用的时候,app也能够正常反应,不要崩溃

效率性 - 启动时间,耗电,流量,界面反应速度在用户容忍的范围以内

上文三点是表象层的东西,是大多数开发者或者团队会着重注意的。除了这三点,还有一些目标是工程方面的也是开发者要注意的:

可修改性/可扩展性 - 软件需要迭代,功能不断完善

容易理解 - 代码能够容易理解

可测试性 - 代码能够方便的编写单元测试和集成测试

可复用性 - 不用一次又一次造轮子

于是,软件设计领域有了几大通用设计原则来帮助我们实现这些目标:

1单一功能原则,最少知识原则,聚合复用原则,接口隔离原则,依赖倒置原则,里氏代换原则,开-闭原则

这里的每一个原则都可以写单独的一篇文章,本文篇幅有限,不多讲解。

基于这些设计目标和理念,软件设计领域又有了设计模式。MVC/MVVM都是就是设计模式的一种。

MVC

历史

二十世纪世纪八十年代,Trygve Reenskaug在访问Palo Alto(施乐帕克)实验室的时候,第一次提出了MVC,并且在Smalltalk-76进行了实践,大名鼎鼎的施乐帕克实验室有很多划时代的研发成果:个人电脑,以太网,图形用户界面等。

在接下来的一段时间内,MVC不断的进化,基于MVC又提出了诸如MVP(model–view–presenter),MVVM(model–view–viewmodel)等设计模式。

组件

MVC设计模式按照职责将应用中的对象分成了三部分:Model,View,Controller。MVC除了将应用划分成了三个??椋苟ㄒ辶四?橹涞?b>通信方式。

Model

Model定义了你的应用是什么(What)。Model通常是纯粹的NSObject子类(Swift中可以是Struct/Class),仅仅用来表示数据模型。

Controller

Controller定义了Model如何显示给用户(How),并且View接收到的事件反馈到最后Model的变化。Controller层作为MVC的枢纽,往往要承担许多Model与View同步的工作。

View

View是Model的最终呈现,也就是用户看到的界面。

优点

MVC设计模式是是一个成熟的设计模式,也是Apple推荐的的设计模式,即使是刚入行的ios开发者也多少了解这个设计模式,所以对于新人来说上手迅速,并且有大量的文档和范例来供我们参考。

在MVC模式中,View层是比较容易复用的,对应Cocoa中的UIView及其子类。所以,github的iOS开源项目中,View层也是最多的。

Model层涉及到了应用是什么,这一层非常独立,但是往往和具体业务相关,所以很难跨App服用。

既然只有Model-View-Controller三个组件,那么剩余的逻辑层代码就比较清楚了,全部堆积到Controller。

通信

MVC不仅定义了三类组件,还定义了组件之间通信的方式。

MVC三个组件之间的通信方式如图

Controller作为枢纽,它指向view和Model的线都是绿色的,意味着Controller可以直接访问(以引用的方式持有)Model和View。

View指向Controller的是虚线,虚线表示View到Controller的通信是盲通信的,原因也很简单:View是纯粹的展示部分,它不应该知道Controller是什么,它的工作就是拿到数据渲染出来。

那么,何为盲通信呢?简单来说当消息的发送者不知道接受者详细信息的时候,这样的通信就是盲通信。Cocoa Touch为我们提供了诸如delegate(dataSource),block,target/action这些盲通信方式。

Model指向Controller的同样也是虚线。原因也差不多,Model层代表的数据层应该与Controller无关。当Model改变的时候,通过KVO或者Notification的方式来通知Controller应当更新View。

这里有一点要提一下:UIViewController往往用来作为MVC中的Controller,MVC中的Controller也可以由其他类来实现。

问题

通过上文的讲解,我们可以看到在纯粹的MVC设计模式中,Controller不得不承担大量的工作:

网络API请求

数据读写

日志统计

数据的处理(JSON<=>Object,数据计算)

对View进行布局,动画

处理Controller之间的跳转(push/modal/custom)

处理View层传来的事件,返回到Model层

监听Model层,反馈给View层

于是,大量的代码堆积在Controller层中,MVC最后成了Massive View Controller(重量级视图控制器)。

为了解决这种问题,我们通?;嵛狢ontroller瘦身,也就是把Controller中代码抽出到不同的类中,引入MVVM就是为Controller瘦身的一个很好的实践。

MVVM

在MVVM设计模式中,组件变成了Model-View-ViewModel。

MVVM有两个规则

View持有ViewModel的引用,反之没有

ViewModel持有Model的引用,反之没有

图中,我们仍然以实线表示持有,虚线表示盲通信

在iOS开发中,UIViewController是一个相当重要的角色,它是一个个界面的容器,负责接收各类系统的事件,能够实现界面专场的各种效果,配合NavigationController等能够轻易的实现各类界面切换。

在实践中,我们发现UIViewController和View往往是绑定在一起的,比如UIViewController的一个属性就是view。在MVVM中,Controller可以当作一个重量级的View(负责界面切换和处理各类系统事件)。

不难看出,MVVM是对MVC的扩展,所以MVVM可以完美的兼容MVC。

对于一个界面来说,有时候View和ViewModel往往不止一个,MVVM也可以组合使用:

Controller解耦

MVC是一个优秀的设计模式,本文讲解MVVM也不是说想要用MVVM来替代MVC。对于软件设计来说,设计模式仅仅是一些参考工具,并没有固定的范式,使用起来是很灵活的。MVVM的很多理念对于Controller解耦是很有帮助的。

SubView

把相关的View放到一个Container View里,这样把对应View的创建,Layout等代码抽离出来,并且由Container统一处理用户交互,回调给外部。(这个比较好理解,就不举例子了)

TableView

关于TableView的Delegate/DataSource解耦,我单独写了一篇博客:

优雅的开发TableView

并且,提供了一个swift开源库,来进行解耦:

MDTable

Layout

在iOS中,视图的Layout一直是代码很乱的一块。通常Layout有两种

手动的计算Frame - 简单粗暴,但是修改起来困难,易读性也不好

通过约束AutoLayout - 有学习成本,并且不好debug,但是修改起来方便,也容易阅读。

通常使用Autolayout,我们都会用一些DSL的三方库:Masonry(OC),SnapKit(Swift)。

以一个常见的Layout为例,以下两图是在一个App中很常见的两种TableViewCell Layout:

两行列表

左边图,右边detail

这里,我们只关心左侧的图,在常规的Layout情况下Cell中的代码:

//Swift代码,使用SnapKit

leftImageView?=?UIImageView(frame:?CGRect.zero)

contentView.addSubview(rightLabel)

//Layout

leftImageView.snp.makeConstraints?{?(maker)?in

????maker.leading.equalTo(contentView).offset(8.0)

????maker.width.height.equalTo(80)

????maker.centerY.equalTo(contentView)

}

于是,两种cell类中,我们把上述代码进行Copy Paste。

那么有没有一种更好的方式进行Layout复用呢?

其实有两种方式进行Layout复用:

继承(由基类提供Layout) 个人不喜欢继承,继承带来的额外的耦合会造成后期维护牵一发而动全身。

Layout独立抽离出来,以协议的方式进行依赖。

这里以第二种方式为例:

首先定义一个协议:来定义可以用来布局

protocol?Layoutable?{

????func?layoutMaker()?->(ConstraintMaker)?->?Void

}

然后,对UIView进行扩展,增加布局方法,同时对于client端隐藏snapKit


extension?UIView{

????func?makeLayout(_?layouter:Layoutable)?{

????????snp.makeConstraints(layouter.layoutMaker())

????}

}

然后,我们定义一个结构体,来表示左侧的正方形布局

struct?LeftSquareLayout?:?Layoutable?{

????func?layoutMaker()?->?(ConstraintMaker)?->?Void?{

????????return{?maker?in

????????????maker.leading.equalTo(self.superView).offset(8.0)

????????????maker.width.height.equalTo(self.length)

????????????maker.centerY.equalTo(self.superView)

????????}

????}

????varlength?:CGFloat

????varsuperView?:?UIView

????init(length:?CGFloat,?superView:UIView)?{

????????self.length?=?length

????????self.superView?=?superView

????}

}

于是,左侧图片的Layout代码变成了如下:

1leftImageView.makeLayout(LeftSquareLayout(length:?80,?superView:?contentView))

工厂

工厂是一个很好的设计模式,你是否不断的在代码里重写类似的代码:


let?titleLabel?=?UILabel(frame:?CGRect.zero)

titleLabel.font?=?UIFont.systemFont(ofSize:?14)

titleLabel.textColor?=?UIColor(colorLiteralRed:?0.3,?green:?0.3,?blue:?0.3,?alpha:?1.0)

titleLabel.text?=?"Inital?Text"

contentView.addSubview(titleLabel)

一般App的字体的大小和颜色都是几种之一,这时候我们用工厂的方式生产实例,能更好的实现代码复用:

定义Label类型:


enum?LabelStyle?{

????casetitle

????casesubTitle

}

定义工厂方法:


extension?UILabel{

????static?func?with(style?initalStyle:LabelStyle)?->?UILabel{

????????switchinitalStyle?{

????????case.title:

????????????let?titleLabel?=?UILabel(frame:?CGRect.zero)

????????????titleLabel.font?=?UIFont.systemFont(ofSize:?14)

????????????titleLabel.textColor?=?UIColor(colorLiteralRed:?0.3,?green:?0.3,?blue:?0.3,?alpha:?1.0)

????????????returntitleLabel

????????default:

????????????returnUILabel()

????????}

????}

}

我们还可以提供两个方法,能够让我们链式的添加到superView和config


extension?UILabel{

????@discardableResult

????func?added(into?superView:UIView)?->?UILabel{

????????superView.addSubview(self)

????????returnself

????}

????@discardableResult

????func?then(config:(UILabel)?->?Void)?->UILabel{

????????config(self)

????????returnself

????}

}

于是,代码变成了这样子

UILabel.with(style:?.title).added(into:?contentView).then?{?$0.text?=?"Inital?Text"}

在结合上文的Layout,我们甚至可以用一个链式的调用完成初始化和Layout

UILabel.with(style:?.title)

????.added(into:?contentView)

????.then?{?$0.text?=?"Inital?Text"}

????.makeLayout(yourLayout)

1?Note:?仅仅举例,实际应用中,你可以需要更好的去设计语法

链式调用的延伸阅读:PromiseKit

ViewModel

在MVC的Controller解耦中,引入ViewModel是一种很常见的方式。把Controller中对应与View相关的逻辑层出来,这样Controller需要做的就是

从DB/网络中获取数据,转换成ViewModel

把ViewModel装载给View

View的属性与ViewModel值绑定在一起(单向)

在Swift中,实现单向绑定是很容易的:

定义一个可绑定类型:


class?Obserable{

????typealias?ObserableType?=?(T)?->?Void

????varvalue:T{

????????didSet{

????????????observer?(value)

????????}

????}

????varobserver:(ObserableType)?

????func?bind(to?observer:@escaping?ObserableType){

????????self.observer?=?observer

????????observer(value)

????}

????init(value:T){

????????self.value?=?value

????}

}

然后,我们扩展UILabel,让其text能够绑定到某一个Obserable值上

extension?UILabel{

????varob_text:Obserable.ObserableType?{

????????return{?value?in

????????????self.text?=?value

????????}

????}

}

接着,建立一个ViewModel


class?MyViewModel{

????varlabelText:Obserable????init(text:?String)?{

????????self.labelText?=?Obserable(value:?text)

????}

}

然后,就可以这么用单向绑定了


let?label?=?UILabel()

let?viewModel?=?MyViewModel(text:?"Inital?Text")

viewModel.labelText.bind(to:?label.ob_text)

//修改viewModel会自动同步到Label

viewMoel.labelText.value?=?"New?Text"

当然,实际使用MVVM的时候,手动实现绑定和View事件回调也可以。

延伸阅读:

RxSwift

ReactiveCocoa

猿题库 iOS 客户端架构设计

网络

网络请求的代码往往也是放到UIController的生命周期里(比如viewDidLoad)或者某些用户的UI操纵。假设你基于以下三个开源库框架进行网络请求和JSON解析

Alamofire

ObjectMapper

AlamofireObjectMapper

我们来模拟一个登录的网络请求,首先定义一个数据结构表示登录的结果

struct?LoginResult:?Mappable{

????vartoken:?String

????varname:?String

????init?(map:?Map)?{/*?*/}

????mutating?func?mapping(map:?Map)?{

????????name?<-?map["name"]

????????token?<-?map["token"]

????}

}

然后,在button点击事件中,进行login

1

2

3

4

5

6

7

8

9

10

11

12

13

14

func?handleLogin(sender:UIButton){

????let?userName?=?"userName"

????let?passWord?=?"password"

????let?url?=?"https://api.example.com/user/login"

????let?params?=?["username":userName,"password":passWord]

????Alamofire.request(url,?method:?.post,?parameters:?params,?encoding:?JSONEncoding()).responseObject?{?(response:DataResponse)?in

????????guard?let?result?=?response.value?else{

????????????print(response.error????"Unknown?Error")

????????????return

????????}

????????print(result.name)

????????print(result.token)

????}

}

这是一个很常规的做法:

在Controller中获取网络请求需要的数据

把请求数据给网络???,网络??楦涸鹎肭笸缡?,并且解析成对象,然后异步回调给Controller

在Controller中处理网络??榛氐鞯慕峁?/p>

这么做有两个问题

1.host,paramter encoding等相关信息对Controller应当透明

2.Controller不应该知道网络层是基于Alamofire的

于是,这里我们把网络层抽离:

首先,定义一个协议,表示能够解析成一个网络请求的类型:

1

2

3

4

5

6

7

protocol?NetworkAPIConvertable?{

????varhost:String?{get}

????varpath:String?{get}

????varmethod:RequestMethod{get}

????varrequestEncoding:RequestEncoding{get}

????varrequestParams:[String:Any]{get}

}

其中,RequestMethod和RequestEncoding是对Alamofire的简单封装

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

enum?RequestEncoding{

????casejson,?propertyList,?url

}

enum?RequestMethod{

????caseget,?post,?delete,?put

}

private?extension?RequestMethod{

????func?toAlamofireMethod()->HTTPMethod{

????????switchself?{

????????case.get:

????????????return.get

????????case.post:

????????????return.post

????????case.delete:

????????????return.delete

????????case.put:

????????????return.put

????????}

????}

}

private?extension?RequestEncoding{

????func?toAlamofireEncoding()->ParameterEncoding{

????????switchself?{

????????case.json:

????????????returnJSONEncoding()

????????case.propertyList:

????????????returnPropertyListEncoding()

????????case.url:

????????????returnURLEncoding()

????????}

????}

}

接着,定义请求的接口

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

struct?APIRouter{

???static?func?request(api:NetworkAPIConvertable,completionHandler:@escaping?(ResponseResult)?->?Void){

????????let?requestPath?=?api.host?+?"/"+?api.path

????????_?=?Alamofire.request(requestPath,

??????????????????????????method:?api.method.toAlamofireMethod(),

??????????????????????????parameters:?api.requestParams,

??????????????????????????encoding:?api.requestEncoding.toAlamofireEncoding())

????????????.responseObject?{?(response:DataResponse)?in

????????????????????????????iflet?value?=?response.value{

????????????????????????????????completionHandler(ResponseResult.succeed(value:?value))

????????????????????????????}else{

????????????????????????????????completionHandler(ResponseResult.error(error:?response.error????NSError(domain:?"com.error.unknown",?code:-1,?userInfo:?nil)))

????????????????????????????}

????}

????}

}

enum?ResponseResult{

????casesucceed(value:Value)

????caseerror(error:Error)

}

于是,我们的网络层封装基本完成了。然后,我们来定义我们的login API

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

enum?NetworkService{

????caselogin(userName:String,password:String)

????//Add?what?you?need

}

extension?NetworkService:?NetworkAPIConvertable{

????varhost:?String?{

????????return"https://api.example.com"

????}

????varrequestEncoding:?RequestEncoding?{

????????switchself?{

????????case.login(_,_):

????????????return.json

????????}

????}

????varrequestParams:?[String?:?Any]?{

????????switchself?{

????????case.login(let?userName,?let?password):

????????????return["username":userName,"password":password]

????????}

????}

????varpath:?String?{

????????switchself?{

????????case.login(_,_):

????????????return"user/login"

????????}

????}

????varmethod:?RequestMethod?{

????????switchself?{

????????case.login(_,_):

????????????return.post

????????}

????}

}

接着,网络请求变成了

1

2

3

4

5

6

7

8

9

10

11

let?userName?=?"userName"

let?passWord?=?"password"

let?login?=?NetworkService.login(userName:?userName,?password:?passWord)

APIRouter.request(api:?login)?{?(response:ResponseResult)?in

????switchresponse{

????case.succeed(let?value):

????????????print(value.token)

????case.error(let?error):

????????????print(error)

????}

}

延伸阅读:Moya

日志

大部分App都会做日志分析,于是你的代码中不得不进行埋点:

1

2

3

4

5

func?tableView(_?tableView:?UITableView,?didSelectRowAt?indexPath:?IndexPath)?{

????tableView.deselectRow(at:?indexPath,?animated:?true)

????//发送日志

????Logger.collectWithContent(....)

}

当你看这样的代码的时候,日志代码也在看着你:

是不是很痛苦呢?

在抽离日志之前,我们想想什么样的日志??槭俏颐窍胍??

尽量不要侵入业务代码

支持由后台动态下发日志统计内容

AOP是一种常见的日志统计解决:

1通过AOP的方式hook所有需要统计的UIView事件回调,然后通过KVC的方式来获取日志需要的数据,是常见的无埋点日志解决方案。

比如很常见的友盟统计需要在viewWillAppear/viewWillDisappear中加入代码:

1

2

3

4

5

6

7

8

9

10

-?(void)viewWillAppear:(BOOL)animated

{

????[superviewWillAppear:animated];

????[MobClick?beginLogPageView:@"Page1"];

}

-?(void)viewWillDisappear:(BOOL)animated

{

????[superviewWillDisappear:animated];

????[MobClick?endLogPageView:@"Page1"];

}

使用AOP的方式,代码变成如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

void?swizzle(Class?cls,SEL?originalSEL,SEL?swizzledSEL){

????Method?originalMethod?=?class_getInstanceMethod(cls,?originalSEL);

????Method?swizzledMethod?=?class_getInstanceMethod(cls,?swizzledSEL);

????method_exchangeImplementations(originalMethod,?swizzledMethod);

}

@implementation?UIViewController?(QTSwizzle)

+?(void)load{

????static?dispatch_once_t?onceToken;

????dispatch_once(&onceToken,?^{

????????swizzle(self.class,?@selector(viewWillAppear:),?@selector(sw_viewWillAppear:)));

????????swizzle(self.class,?@selector(viewWillDisappear:),?@selector(sw_viewWillDisappear:)));

????});

}

-?(void)qt_viewWillAppear:(BOOL)animated{

????[self?qt_viewWillAppear:animated];

????//?Log代码

}

-?(void)qt_viewWillDisappear:(BOOL)animated{

????[self?qt_viewWillDisappear:animated];

????//?Log代码

}

@end


可以看到,我们通过AOP,在原有的viewWillAppear后动态插入的日志代码,其他点击事件也可以类似处理。另外,Objective C有一个很方便的用来做AOP的开源框架:Aspects

细心的同学可能看到了,这块的代码我是以Objective C作为例子的,因为OC的Runtime特性,可以很方便的做AOP。对于NSObject及其子类,Swift也支持AOP,但是考虑到Swift的语言特性,关于Swift的无侵入日志,也许还可以方案:

一套支持日志统计的框架。这个看起来工作量很大,但其实需要做大量日志统计的公司往往都有自己的一套XXUIKit,在基类里加入日志统计的基础逻辑也未尝不可

编译期AOP。这个仅局限于理论,就是

延伸阅读:

iOS无埋点数据SDK实践之路

消息转发机制与Aspects源码解析

数据存储

iOS常用的本地数据存储方案有几种:

UserDefaults 用户配置信息

File/Plist 少量的无须结构化查询的数据

KeyChain 密码/证书等用户认证数据

数据库 需要结构化查询的信息

iCloud

而数据库往往是App的数据核心。在iOS中:可以选择数据库技术有

CoreData - 对应开源库MagicalRecord

Sqlite直接封装 - 对应开源库 FMDB

Realm

CoreData的坑比较多,想要用好需要比较高的学习成本。Relam和Sqlite都是建立结构化查询数据库的比较好的选择。

使用FMDB,你的代码类似这样子的。

1

2

3

4

5

6

7

8

9

let?queue?=?FMDatabaseQueue(url:?fileURL)

queue.inTransaction?{?db,?rollback?in

????do{

????????trydb.executeUpdate("INSERT?INTO?foo?(bar)?VALUES?(?)",?values:?[1])

????????trydb.executeUpdate("INSERT?INTO?foo?(bar)?VALUES?(?)",?values:?[2])

????}?catch{

????????rollback.pointee?=?true

????}

}

可以看到,FMDB是把sqlite从C的API封装成了Objective/Swfit等上层API。但是还是缺少了两项比较核心的

ORM(Object Relational Mapping)从数据库的表映射到Structs/Class

查询语言。在代码里进行SQL字符串的编写是繁琐的也容易出问题

于是,通常你需要在FMDB(Sqlite)上在进行一层封装,这一层封装提供ORM和查询语言。从而更有好的提供上层接口。类似的框架有:

WCDB 微信最近开源的数据库

GYDataCenter

延伸阅读:

微信移动端数据库组件WCDB系列(一)-iOS基础篇

微信移动端数据库组件WCDB系列(二) — 数据库修复三板斧

微信iOS SQLite源码优化实践.md

路由

在iOS开发中,UIViewController之间的跳转是无法避免的一个问题。比如,一个ViewControllerA想要跳转到ViewControllerB

1

2

3

4

#import?"ViewControllerB.h"

//...

ViewControllerB?*?vcb?=?[[ViewControllerB?alloc]?init];

[self.navigationController?pushViewController:vcb?animated:YES];

当在一个类中import另一个类的时候,这两个类就形成了强耦合。

另外,很多App都有一个用户中心的界面,这个界面有一些特点就是会跳转到很多界面。于是,日积月累,这个类中,你会发现代码编程了这个样子:

1

2

3

4

5

6

7

8

ifindexPath.secion?==?0{

????ifindexPath.row?==?0{


????}elseif....

}elseifindexPath.section?==?1{


}

....

大量的if/else造成代码难以阅读,并且难以修改。

一个典型的解Controller与Controller解耦方案就是加一个中间层:路由,并且建立Module(??椋├垂芾硪蛔镃ontroller。

类似这种的路由架构,在App启动的时候,通过注入的方式把各个Module

一个典型的跳转请求如下:

ControllerA发起跳转请求Request

Router解析Request,轮询问各个Module,看看各个Module是否支持对应的Requst。

如果有则把requst转发给对应的Module;

?如果没有,根据Request的内容可选请求远端服务器,服务器可能返回H5地址

Router根据远端服务器,或者Module的Response,合成跳转的command,发送给导航???/p>

导航模块根据command进行跳转,并且返回feedBack给Router

Router返回feedback给ControllerA

总结

iOS App是一个麻雀虽小,五脏俱全的软件。良好的架构和设计能够让代码容易理解和维护,并且不易出错。关于App的设计一个仁者见仁,智者见智的问题,并没有什么固定的范式。本文也只是提出了笔者的一些经验,仅供参考,

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

推荐阅读更多精彩内容