很好懂的Swift MVVM in Rx

并不是说Rx就是MVVM,只是用了Rx,才能更有MVVM的赶脚,毕竟iOS原生的MVC框架没那么好改变~

所以你以为接下来要写什么是MVVM了嘛?自己去百度吧

所以你以为接下来我要写什么是Rx了嘛?自己去Github吧

首先有些概念

1. MVVM有哪些文件

简单来说就是我们有ControllerViewModel两个文件,去做一坨东西,Controller操作UI,ViewModel操作业务,(嗯,这个是整篇里说的最官方的了)

2. ControllerViewModel怎么交互?

当然如果我说Controller里写viewModel.doSth()这样,你肯定就不会看下去了;

所以我们引入一个【节点】的概念,节点有两三种,

一种是凸凸的节点,代表着他会向外发射一个能量,至于这能量是个啥,你先别管~对于凸凸,通常情况下你只需要给他来一发就完了,也可能你需要包装一下里面的能量,比如把猫粮装到盒子包起来然后再发出去

一种是凹凹的节点,代表着他需要外面给他来一发能量,这个要做的事情比较多,你收到能量后需要拿着他去做接下来的事,这是你对应的凸凸需要你完成的,或者说你需要把快递拆了,然后把猫粮拆了,然后去喂猫,然后观察猫是不是正常,是不是喜欢吃,然后如果不正常还需要退货去骂人~

一种是凹凸的节点,这就很牛皮了他既能来一发又能收一发,所以上面提到的他都要干

而至于能量/快递盒子是神马?方法传递时候不是需要带参数么,你把参数当成能量也阔以~

有点抽象?看下面

class ViewController: UIViewController {
    let viewModel = ViewModel()
    func buttonTapped()
    {
        viewModel.dosth(一发能量)
    }
}

这是普通版本的,而在节点之下,Controller中会有个凸凸的节点,而ViewModel就会有个凹凹的节点,然后用厉害的手段把他们结合起来。

class ViewController: UIViewController
{
    let signalOutput = 凸凸<能量>()
    
    override func viewDidLoad() {
        
        // 把凸凸和凹凹结合到一起
        // 别想那些羞射的事情,不是那样的!
        putTogether(signalOutput , signalInput)
    }
}

class ViewModel
{
    let signalInput = 凹凹<能量>()
}

所以说白了就是:
我们把所有方法调用变成了属性,所以我们不再存在方法调用了,方法会浓缩到属性里去,当然你如果把【凹凹】理解成封装了个closure,那我觉得你已经领悟了很多了~,所以下面你应该有这么个概念,

Button点击之类的事情,我就需要有个【凸凸】,同时我需要在ViewModel中找个【凹凹】,把他们捏一起让他们肚子里的能量传递起来,

不过Button点下去,你啥都没,所以只是一个空的能量在那传递,传递还是会传,只是没什么内容,就像你收到一个空的快递盒子一样~

还是不理解的话试一下最后一个场景,把凸凸想成送快递的,把凹凹想成收快递的,而里面的能量就是快递盒子,再想一下,如果还想不通,那就回过去再看看吧~






所以我们从三个简单的实际场景看下,在我当前的思维模式下,是怎么把简单问题变得灰常复杂的,这其实就是MVVM干的事情~

场景一:支付场景下的简单判断

现在有个按钮,点击后触发一个请求告诉我们用户有木有设置过支付密码/TouchId/甚至FaceId,怎么高端你就怎么想;

如果有设置过,直接让用户输入密码/摸手机/秀脸,操作完成后带着密码提交请求,然后完成支付。流程结束

如果木有设置过,那就跳转到设置页面结束。流程结束

这应该描述起来很简单,但是如果按照普通的写法,不是Controller会变的很肥,就是流程间的交互Closure会很多

所以该怎么把这个看着简单的东西解析成看着灰常复杂的场景呢~

1. 首先我们要知道Controller要做些啥

  • 按钮点击
  • 弹个密码框让用户输
  • 跳转去设置密码

上面是在MVVM中,Controller需要做的事,只有这些,没有别的了

所以这三个事情一定会对应三个节点了,那哪些是凸凸哪些是凹凹呢?
对于Controller来说很简单的概念就是,所有从View触发/或者说从界面触发的东西都是凸凸,比如按钮点击,比如列表cell点击等。

所以上面这三个

  • 按钮点击 【我是凸凸】
  • 输入完成密码【我是凸凸】
  • 弹个密码框让用户输 【我是凹凹】
  • 跳转去设置密码 【我是凹凹】

可能我们会问,为什么下面2个是凹凹,因为他不也是触发UI嘛?但是如果这样问,谁让你的按钮点击了,谁让你弹个密码框了,谁让你跳转了,你就知道了,除了第一第二个是用户点的,其他两个其实都是别的代码让你触发的,所以在这两个文件中,只有可能是ViewModelController去做了这两件事


2. ViewModel要做些啥

  • 一个查询是不是有支付密码的请求
  • 处理是不是有支付密码然后分发
  • 告诉Controller弹出密码框
  • 告诉Controller去设置密码
  • 接受个密码
  • 一个提交支付密码的请求
  • 告诉Controller支付成功了

ViewModel要做的事就多了,但是我们简单解析后就这些了,还是和上面一样,我们把凸凸凹凹分析一下,ViewModel如果理解了,那就真的明白这个概念了,其实增的很简单。

  • 一个查询是不是有支付密码的请求 【凹凹】
  • 处理是不是有支付密码然后分发 【凹凹】
  • 告诉Controller弹出密码框 【凸凸】
  • 告诉Controller去设置密码 【凸凸】
  • 接受个密码 【凹凹】
  • 一个提交支付密码的请求 【凹凹】
  • 告诉Controller支付成功了 【凸凸】

其实凸凸很好理解,都告诉Controller做事了,还不是凸凸也真是服。而请求发起者在这个场景下都是来自于Controller,而第二个处理是否有密码的节点其实只是个过度节点,而同样的过度还会在请求中出现,当然这之后再说;

所以查询支付密码后,请求会有个能量传递给【处理是否有支付密码的分发】,这就属于内部凸凸凹凹了,用的顺的话这就会很习惯。

在有了这些概念后来看张整合的图:


image

这里有几个是内部向下箭头,其实凸凸也是可以接受内部来的能量的,换句话说他对外可能是凸凸,对内,那就未必咯~


3. Controller的凹凹

首先你要知道凸凸和凹凹是个啥~,我们就非常生硬的引入了RxSwift的概念,反正只是个类名而已,不会影响大家任何思路:
我们把单个节点定义成:

let signalOutput = PublishRelay<Void>()

其中PublishRelay就是凸凸和凹凹的类型,其实他们是同一个类的,只是从功能上区分了凸和凹的概念,在代码上没什么差别。
另外PublishRelayPublishSubject的简单封装,作用一毛一样,区别就是前者在RxCocoa中,而后者在RxSwift中,所以单纯用这个,我们就可以来看下Controller会变成什么样:

class ViewController: UIViewController {
    let button = UIButton()
    // 按钮点击
    let buttonTapOutput = PublishRelay<Void>()
    
    // 跳转去设置支付密码
    let gotoSetpswInput = PublishRelay<Void>()
    
    // 弹密码输入框
    let showSetpswInput = PublishRelay<Void>()
    
    // 输入密码完成
    let finishedSettingpswOutput = PublishRelay<String>()
    
    // 流程完成
    let allFinishedInput = PublishRelay<String>()
    
    override func viewDidLoad() {
        
        button.rx.tap
            .bind(to: buttonTapOutput)
            .disposed(by: rx.disposeBag)
        
        gotoSetpswInput.subscribe(onNext: { (_) in
            // 此处添加navigate跳转逻辑
            print("跳转啦啦啦")
            
        }).disposed(by: rx.disposeBag)
        
        showSetpswInput.subscribe(onNext: { (_) in
            // 此处添加弹出密码设置
            print("设置密码啦啦啦")
            
        }).disposed(by: rx.disposeBag)
        
        allFinishedInput.subscribe(onNext: { (_) in
            print("all done")
            
        }).disposed(by: rx.disposeBag)
    }
}

如果无法理解,你可以理解成bind就是把凸凸和凹凹结合的方法,当然也可以连接两个凸凸,这样相当于两个快递员之间的传递,而subscribeNext 的用法在这就是给凹凹添加一系列处理事件,他在获取到能量后需要做一堆事,比如我们可以在设置密码处弹框,在跳转处写navigationController.push()...

再详细点描述,其实bindsubscribe的用法是一样的,只是bind给另一个节点,相当于多了个快递传送,但是最后的closure肯定需要的,只是你什么时候写而已,而subscribe就是你懒的找快递了直接开箱验货了而已

上面的代码中其实只处理了Controller的input,在那张图上就是箭头指向Controller的部分,因为这就是Controller要做的事,所以在MVVM崇尚VC/VM分离的情况下,我们可以毫无干涉的先把Controller要做的部分完善掉,无需考虑任何别的业务逻辑,这也是隔离的一个好处


4. ViewModel的凹凹

从上面这点我们可以看出,当我们划分清楚了凹凹凸凸,我们的开发顺序会变的顺畅,处理完Controller的凹凹之后,我们接下来需要处理ViewModel的凹凹,因为凹凹都是当前类肚子中的逻辑,所以会很好做

class ViewModel {
    let disposeBag = DisposeBag()
    
    // 查询是否有支付密码,带请求
    let checkPswInput = PublishRelay<Void>()
    
    // 处理查询结果
    let checkeNeedPswDispatch = PublishRelay<Bool>()
    
    // 去设置密码
    let gotoSetpswOutput = PublishRelay<Void>()
    
    // 弹出框输入密码
    let showSetpswOutput = PublishRelay<Void>()
    
    // 密码输入完成
    let setPswFinishInput = PublishRelay<String>()
    
    // all done
    let finishedAllOuput = PublishRelay<Void>()
    
    init() {
        
        // flatMap后的内容其实应该用一个请求代替,这里简略一下直接把返回值标出来了
        // 一般Rx的请求返回的结果就是Observable<T>的
        checkPswInput
            .flatMap { Observable.just(true) }
            .bind(to: checkeNeedPswDispatch)
            .disposed(by: disposeBag)
        
        checkeNeedPswDispatch.subscribe(onNext: { (hasPsw) in
            if hasPsw {
                self.showSetpswOutput.accept(())
            }
            else {
                self.gotoSetpswOutput.accept(())
            }
        }).disposed(by: disposeBag)
        
        setPswFinishInput
            .flatMap { _ in Observable.just(true) }
            .map { _ in () }.bind(to: finishedAllOuput)
            .disposed(by: disposeBag)
    }
}

因为重点不在请求,所以我们简单忽略请求部分,把最终结果贴了上来,flatMapMap简单来说就是快递拿到你的包裹给你加了个盒子或者拆了个盒子换了个信封之类的,虽然这很不道德,但是在Rx中这再正常不过,虽然这有点响应式的概念,如果想了解的话可以点一下看看,不过如果看不懂:

所谓响应式,就是你在网上买了袋猫粮,卖家把货给一个快递,途中他可能给你装盒子,拆盒子,拿出来尝一口,给你换一袋便宜的,交给另一个快递小哥,搭个飞机乘个坦克,然后给你重新买袋正品,最后送到你的手上的还是一袋东西,那至于他是不是你想要的猫粮,就要看中间的过程了,但是这件事就是有始有终的一整个工作流,途中凹凹凸凸凸凸凹凹啥的一大堆,最终就是一袋东西在那传,送到你手上。

好了不扯远,在上面的代码之后,其实我们已经可以为Controller写好凸凸来绑定ViewModel的凹凹,反之亦然

5. ControllerViewModel的凸凸凹凹绑定

class ViewController: UIViewController
{
    let button = UIButton()
    
    let viewModel = ViewModel()
    // 按钮点击
    let buttonTapOutput = PublishRelay<Void>()
    
    // 跳转去设置支付密码
    let gotoSetpswInput = PublishRelay<Void>()
    
    // 弹密码输入框
    let showSetpswInput = PublishRelay<Void>()
    
    // 输入密码完成
    let finishedSettingpswOutput = PublishRelay<String>()
    
    // 流程完成
    let allFinishedInput = PublishRelay<Void>()
    
    override func viewDidLoad() {
        
        button.rx.tap
            .bind(to: buttonTapOutput)
            .disposed(by: rx.disposeBag)
        
        gotoSetpswInput.subscribe(onNext: { (_) in
            // 此处添加navigate跳转逻辑
            print("跳转啦啦啦")
            
        }).disposed(by: rx.disposeBag)
        
        showSetpswInput.subscribe(onNext: { (_) in
            // 此处添加弹出密码设置
            print("设置密码啦啦啦")
            
        }).disposed(by: rx.disposeBag)
        
        allFinishedInput.subscribe(onNext: { (_) in
            print("all done")
            
        }).disposed(by: rx.disposeBag)
        
        
        // Controller <=> ViewModel 绑定
        buttonTapOutput
            .bind(to: viewModel.checkPswInput)
            .disposed(by: rx.disposeBag)
        
        finishedSettingpswOutput
            .bind(to: viewModel.setPswFinishInput)
            .disposed(by: rx.disposeBag)
        
        viewModel.gotoSetpswOutput.bind(to: gotoSetpswInput).disposed(by: rx.disposeBag)
        viewModel.showSetpswOutput.bind(to: showSetpswInput).disposed(by: rx.disposeBag)
        viewModel.finishedAllOuput.bind(to: allFinishedInput).disposed(by: rx.disposeBag)
    }
}

我们在ViewDidLoad最后添加了点绑定方法,这样,这个场景中所有线的凸凸凹凹就全部被结合到一起了

就这样,一个看似复杂的业务流程被解析成了更复杂的MVVM的Rx形态,不过是不是在开发思路上顺畅了不少呢?大概这就是为什么我们会用这套东西的原因吧


场景二:添加删除列表某一项

为什么拿这么简单的场景举例,因为这能很好的体现这套思维的另一个特征,如果说场景一中是带着你绕圈子,作用是强化一个个节点的概念;那这个场景就是强调【数据驱动】的概念了

简单来说就是你一定希望,我的Array中少一项,那以他为数据源的tableView就会自动少一行。其实这个场景就是帮助我们达到这个目的的

所以我们先请我们的数据源来亮相一下:

var sectionedData: BehaviorRelay<[MGItem>]>!

有人会说你作弊,BehaviorRelay又是啥,其实他就是个有货的凸凸凹凹,在前面的PublishRelay中那是个快递的角色的话,记住PublishRelay只会传递能量,他不会留存你的能量,即快递拿到你的货物后,给到下一个之后快递是不会保留的你猫粮的,毕竟他不养猫留着也没啥用。

但是在这个场景中,我们需要一个长期持有能量的节点来充当数据源,毕竟你的数据源如果只是个快递小哥,那他可能啥都没~,

所以我们用一个很夸张的例子来理解BehaviorRelay,把他理解成你善良的母亲大人就行了,有一天你把猫粮给你的母亲大人,让她帮你传达给你的伙伴,于是这袋猫粮就永远留在了你母亲大人的心里,虽然最后她会交给你的伙伴,但是如果这时候你说,我还要一袋,我还要两袋,你母亲大人总能帮你变出来,因为她已经记住了猫粮长什么样了,所以你就可以理解,你无限的索取,你母亲大人会帮你变出一集装箱的猫粮,而且他们是同一个样子的。

专业点就是,你母亲大人不停给你猫粮指针,指向同一个地址,地址就在她脑子里,所以哪天你说我要狗粮的时候,你母亲大人的脑子里就会立马变成狗粮,巴不得她把所有猫粮全变成狗粮,虽然现实中她做不到。

有点啰嗦,所以BehaviorRelay就是可以永久持有一个能量,并且你做任何改动,能量都会被永久变化的东西,用他来做数据源再好不过。

1. 还是先从数据源定义开始

func sectionableData() -> BehaviorRelay<[MGSection<MGItem>]> {
    let item1 = MGItem(str: "1")
    let item2 = MGItem(str: "2")
    let item3 = MGItem(str: "4")
    let item4 = MGItem(str: "5")

    let section1 = MGSection(header: "header1", items: [item1, item2])
    let section2 = MGSection(header: "header2", items: [item3, item4])

    return BehaviorRelay(value: [section1, section2])

}

我们用这个方法来创建一个数据源,MGSection可能有点陌生,其实就是我们现在需要的是带Section的列表,而SectionHeader就是header1,header2

由于Rx本身并没有MVVM的数据驱动的概念,所以我们需要引入RxDataSouces这个神秘的库来帮我们完善数据源,SectionModelType就是RxDataSouces中定义的协议了

/// MGSection model
public class MGSection<ItemElement>: SectionModelType {

    public typealias Item = ItemElement

    public var header: String = ""

    public var items: [Item] = []

    init() {

    }

    public required init(original: MGSection, items: [Item]) {
        self.header = original.header
        self.items = items
    }

    /// 初始化调用我就行了
    ///
    /// - Parameters:
    ///   - header: header string
    ///   - items: items
    public convenience init(header: String, items: [Item]) {
        let section = MGSection<Item>()
        section.header = header
        section.items = items

        self.init(original: section, items: items)
        self.header = header

    }
}

其实看不懂也无所谓,简单理解就是我们创建一个普通的数组,然后用RxDataSouces所要求的格式进行封装后得到了BehaviorRelay<[MGSection<MGItem>]>这么一个数据源,仅此而已。

2. Controller的绑定

override func viewDidLoad() {
    super.viewDidLoad()

    viewModel.initial()
    
    viewModel
        .sectionedData.asObservable()
        .bind(to: tableView, by: { (_, _, _, item) -> UITableViewCell in
            let cell = self.tableView.dequeueReusableCell(withIdentifier: "cell")
            cell?.textLabel?.text = item.name
            return cell!
        }).disposed(by: disposeBag)

}

这样就看得懂了,虽然这个bind方法有点让人无法理解,毕竟这是个封装,内容在这里:

func bind<RowItem>(to tableView: UITableView, by configCell : @escaping
    (TableViewSectionedDataSource<MGSection<RowItem>>,
    UITableView,
    IndexPath,
    RowItem) -> UITableViewCell )
    -> Disposable
    where E == DataSourceWithRequest<RowItem> {
        
        let realDataSource = RxTableViewSectionedReloadDataSource<MGSection<RowItem>>(configureCell: configCell)
        
        realDataSource.titleForHeaderInSection = { ds, index in
            return ds.sectionModels[index].header
        }
        
        return self.bind(to: tableView.rx.items(dataSource: realDataSource))
}

emmm... 怎么说呢,这已经是我能提供的最能看得懂的版本了,还是那句话,这里不多讲Rx,所以最浅显的理解就是我们把 RxDataSources封装好的数据源bind给了tableView,毕竟bind这个动作之前已经见过了

image

大概样子就是这样,这也太简单了,所以接下来要给Add Item 按钮搞点事情了

3. 改下数据源会怎么样

 addBar.rx.tap.map{}.subscribe(onNext: { (_) in
       print("")
       self.viewModel.sectionedData.accept([])
 })

我们粗暴的用上面这个姿势来修改,.accept其实就是修改了你母亲大人脑中的猫粮,当然这里是把猫粮吃完了~,而仅仅这一步操作后,其实tableView就已经刷新了,即一个cell都木有了。

所以这就是我们要达到的目的,与之前MVC中把数据源清空后再tableView.reloadData()的操作相比,这样更纯粹,而之前其实我们即操作了数据源,又刷新了tableview,为了做删除数据这个操作,我们改了2个部分的代码,俗话说得好,改的多错的越多~

当然有人会说其实这个修改数据源后tableview直接变的底层还是这么回事,那我只能说,毕竟那不是我们自己改的,错的不可能是我们~

当然有个不怎么优雅的点大家应该可以发现,说是说清空数据,其实我们做的并不是removeAll的操作,而是新建了个空数组,换句话说这时候的指令并不是告诉你母亲大人请把猫粮吃完,我现在不想要了;而是我们直接给她一个空袋子说,你手上的猫粮吃完了,这种看似骗自己的手法确实有点尴尬,而由于BehaviorRelay中存储的能量是不可编辑的,所以我们只有通过覆盖原本的数组,来达到所谓的删除目的。

所以如果是一个添加动作,原本是[1,2],我们只要给个新数组[1,2,3]tableView做的其实就是insertSection动作,至于为什么,RxDataSources做了你想知道的一切,这里就不多扩展了。

4. MVVM一点呢?

之前提到过MVVM会让简单事情复杂化,所以上面这个例子明显并不MVVM,毕竟你怎么可以直接在按钮点击事件中去操作ViewModel中的业务数据呢?说好的节点和快递呢?

这种来自灵魂深处的拷问让我们无法作答,所以还是往下看吧。

addBar.rx.tap.map{}.bind(to: viewModel.addObjInput)

所以我们把Controller改成了这样,

同时为了看着美观,我们给BehaviorRelay加一个add方法来欺骗欺骗我们自己:

extension BehaviorRelay
{
    func add<T>(element: T) where Element == [T]
    {
        var newValue = self.value
        newValue.append(element)
        self.accept(newValue)
    }
}

上面提到过其实我们是用替换来充当add,所以这里就露骨点这样写了,虽然外面调用的时候我们可以心安理得的用add了。

所以在ViewModel中,最终节点订阅会变成:

    addObjInput.subscribe(onNext: { (_) in
        let item5 = MGItem(str: "5")
        let item6 = MGItem(str: "6")
        
        let section1 = MGSection(header: "header3", items: [item5, item6])
        self.sectionedData.add(element: section1)
    })

g结果就是这样,你会问既然是add那为什么没动画,请相信我,这真的是add,只是我调了个没动画的RxDatasource的封装,仅此而已。

image

至此,你会发现我们改了数据源,tableView就出现了变化,这就是MVVM的另一个核心。


场景三:简单的不能简单的计数label

image

图中的需求其实很简单,你会说button点击时候改变全局变量就行了,再给label刷新一下就完了,但是你发现其实你又操作了数据,又操作了label,仿佛又有些不太对,
因为这个场景只是个巩固,所以我们就直接贴全部代码了:
Controller:

class ViewController: UIViewController {

    //MARK : - UIs
    @IBOutlet weak var countLabel: UILabel!
    
    @IBOutlet weak var countButton: UIButton!


    let disposeBag = DisposeBag()

     let vm = ViewModel()

    //MARK : - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.counter.map { "\($0)" }.bind(to: countLabel.rx.text)
        countButton.rx.tap.bind(to: viewModel.input)
    }
}

ViewModel:

class ViewModel {
    let input: PublishRelay<Void> = PublishRelay()

    let counter: BehaviorRelay<Int> = BehaviorRelay(value: 1)
    
    init() {
        input.map { _ in self.counter.value + 1 }.bind(to: counter)
    }
}

同样我们把所有事情移到了ViewModel,同样我们省去了刷新Label这么一个操作,绑定配置之后,我们就可以安心去对我们的ViewModel做所有业务操作了,其实严格意义上来说{ "\($0)" }这个操作也应该在ViewModel中,奈何实在是不想多写了,我就写了个反面教材以示警戒吧~

至此,简单的入门应该真的入了,如果还没有,那就只能留言作者或者强行怼作者了~

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

推荐阅读更多精彩内容