【Flutter】Flutter插件开发之创建iOS端插件

创建Flutter插件工程

Android Studio里点击Flie - New - New Flutter Project,在左侧里选中Flutter,然后点击Next

创建工程.png

  • Project Name里输入项目名,只能是小写英文
  • Project type里选择Plugin
  • Organization里写包名,.Project Name会拼在包名的最后面成为包名的一部分

也可以使用命令行flutter create --org com.example --template=plugin plugin_name来创建插件,其中com.example就是Organizationplugin_name就是Project Name

点击Finish后就成功创建一个插件工程了。
创建成功后可能默认打开的是Android工程,点击切换为Project

默认工程.png

切换工程.png

切换后可以看到很多文件夹,我们需要关注的主要有以下4个:

  • android目录是用来开发Android端的插件功能
  • ios目录是用来开发iOS端的插件功能
  • lib是实现Flutter插件接口的代码
  • example目录是测试项目,用来测试开发出来的插件的

打开iOS工程

ios目录只是一些零散的文件,是没有工程的,所以我们怎么打开它来编写呢???
我们注意到,在example里也有一个iOS工程,没错,这个才是真正的工程!!!
但是我们打开发现会报错,也没有插件的文件,那怎么办呢?

Xcode虚假的工程.png

在打开工程之前,我们需要在Android Studio里的命令行执行以下命令:

cd example
flutter run  

等执行完成之后,我们就可以打开Runner.xcworkspace文件了,这个时候我们发现,多了一个Pods工程。这个工程其实就是插件工程。
我们在Pods工程的Development Pods目录下,找到Project Name的文件夹,一直展开,最后就看到插件的文件了,我们就是在这个文件下编写代码。

Xcode真正的工程.png

编写插件代码

本文采用的语言是Swift
找到Swift项目名Plugin.swift这个文件,该文件就是插件的实现文件。
register方法里,我们注册了一个通道(已经默认注册了),通道名默认就是项目名,该名字在通信里必须是唯一的,可以修改,一旦修改,需要把dartandroid里的名字也一并修改。
handle方法里,实现Flutter调用原生的API,其中call.method就是方法名,call.arguments就是Flutter传递过来的参数。使用result(返回值)可以把结果返回给Flutter
当找不到方法名时,可以返回FlutterMethodNotImplementedFlutter表示该方法还没实现,以此来做版本兼容。
具体实现如下:

public class SwiftNakiriPlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    let channel = FlutterMethodChannel(name: "nakiri", binaryMessenger: registrar.messenger())
    let instance = SwiftNakiriPlugin()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
      if call.method == "stateString" { // 获取网络状态的实现
          result(ZTNetworkStateManager.shared.stateString)
      } else if call.method == "bonusPoints" { // 使用参数的实现
          let array = call.arguments as! Array<Int>
          result(array[0] + array[1])
      } else if call.method == "getPlatformVersion" { // 默认的实现
          result("iOS " + UIDevice.current.systemVersion)
      } else {
          // 找不到方法
          result(FlutterMethodNotImplemented)
      }
  }
}

Objective-C的插件文件名是项目名Plugin.m,注册方法是registerWithRegistrar,实现插件内容的方法是handleMethodCall

使用第三方库

写插件不可避免的会用到第三方库,在使用第三方库的时候,会遇到3种情况:

  • 仅原生端使用第三方库
  • Flutter端使用第三方库
  • 都使用同一个第三方库

不同的情况有不同的处理方式。

仅原生端使用第三方库

当仅原生端需要依赖某些第三方库时,可以在项目名.podspec文件里加上s.dependency '第三方库名',如:

s.dependency 'Alamofire'

然后打开命令行,跳转到Runner.xcworkspace所在的目录,然后pod install即可。

仅Flutter端使用第三方库

当仅Flutter端需要依赖某些第三方库时,可以在pubspec.yaml文件里的dependencies部分,如:

dependencies:
  flutter:
    sdk: flutter

  url_launcher: ^6.0.16

之后在Android Studio里执行Pub get就行了。

都使用同一个第三方库

假设Flutter里需要用到url_launcher,然后原生里也需要用到,那我们就得在Flutterpubspec.yaml文件里的dependencies部分添加依赖包,同时也要在iOS端的项目名.podspec文件里加上s.dependency 'url_launcher'

Flutter端实现

刚才我们已经在插件里增加了一个名字叫stateString的方法,但是Flutter端还没实现,我们现在去把它实现。
找到lib文件夹下的项目名.dart文件,里面就有一个类,类名就是项目名,我们增加一个方法用来调用iOS端的stateString方法,方法名不需要和iOS端的保持一致,主要是通道里调用iOS端的方法名就行了,代码如下:

class Nakiri {
  static const MethodChannel _channel = MethodChannel('nakiri'); /// 通道名,需和iOS、android端保持一致

  /// 默认实现
  static Future<String?> get platformVersion async {
    final String? version = await _channel.invokeMethod('getPlatformVersion');
    return version;
  }

  /// 实现iOS端新增的方法
  static Future<String> stateString() async {
    final String state = await _channel.invokeMethod('stateString');
    return state;
  }

  /// 实现iOS端新增的方法
  static Future<int> add() async {
    final int result = await _channel.invokeMethod('bonusPoints', [5, 8]); /// 接收一个数组或者字典作为参数传递给原生端
    return result;
  }
}

Flutter端测试的实现

example目录里的lib目录,里面有一个main.dart文件,该文件就是测试使用的文件,我们在它本来的实现上修改一下,代码如下:

class _MyAppState extends State<MyApp> {
  String _platformVersion = 'Unknown';
  String _stateString = 'Unknown';
  int _add = -2;

  @override
  void initState() {
    super.initState();
    initPlatformState();
  }

  Future<void> initPlatformState() async {
    String platformVersion;
    String stateString;
    int add;

    try {
      platformVersion =
          await Nakiri.platformVersion ?? 'Unknown platform version';
      stateString =
          await Nakiri.stateString();
      add =
      await Nakiri.add();
    } on PlatformException {
      platformVersion = 'Failed to get platform version.';
      stateString = 'Failed to get stateString';
      add = -1;
    }

    if (!mounted) return;

    setState(() {
      _platformVersion = platformVersion;
      _stateString = stateString;
      _add = add;
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin example app'),
        ),
        body: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Center(
              child: Text('Running on: $_platformVersion\n'),
            ),
            Center(
              child: Text('Network is: $_stateString\n'),
            ),
            Center(
              child: Text('Bonus points is: $_add\n'),
            ),
          ],
        ),
      ),
    );
  }
}

需要注意的是,Flutter和原生通信都是异步的,所以都需要使用awaitasync

Flutter测试.png

通信的数据类型

原生与Flutter互相通信时使用的数据类型是有限制的,以下是可用的数据类型:

Dart kotlin Java Objective-C Swift
null null null NSNull NSNull
bool Boolean java.lang.Boolean NSNumber numberWithBool: NSNumber(value: Bool)或者Bool
int 32位平台 Int java.lang.Integer NSNumber numberWithInt: NSNumber(value: Int32)或者Int32
int Long java.lang.Long NSNumber numberWithLong: NSNumber(value: Int)或者Int
double Double java.lang.Double NSNumber numberWithDouble: NSNumber(value: Double)或者Double
String String java.lang.String NSString String或者NSString
Uint8List ByteArray byte[] FlutterStandardTypedData typedDataWithBytes: FlutterStandardTypedData(bytes: Data)
Int32List IntArray int[] FlutterStandardTypedData typedDataWithInt32: FlutterStandardTypedData(int32: Data)
Int64List LongArray long[] FlutterStandardTypedData typedDataWithInt64: FlutterStandardTypedData(int64: Data)
Float32List FloatArray float[] FlutterStandardTypedData typedDataWithFloat32: FlutterStandardTypedData(float32: Data)
Float64List DoubleArray double[] FlutterStandardTypedData typedDataWithFloat64: FlutterStandardTypedData(float64: Data)
List List java.util.ArrayList NSArray Array或者NSArray
Map HashMap java.util.HashMap NSDictionary Dictionary或者NSDictionary
  • 从表里可知,Swift的基础类型可以用Objective-C的对象类型,集合类型可以兼容Objective-C的集合类型(不过这些都是Swift本身的特性
  • 在使用Swift时,最好还是使用它本身的类型,如果使用Objective-C的类型,就无法判断详细类型,比如IntDouble,在使用Objective-C类型的时候,都是NSNumber

原生与Flutter通信

上面都是讲Flutter怎么调原生的,那原生能不能主动去调Flutter呢?
我们先看看Flutter提供的3个通信类:

  • FlutterMethodChannel用于方法调用
  • FlutterBasicMessageChannel用于传递简单数据
  • FlutterEventChannel用于监听数据流

每种Channel均有三个重要成员变量:

  • name:String类型,代表Channel的名字,也是其唯一标识符
  • messager:BinaryMessenger类型,代表消息信使,是消息的发送与接收的工具
  • codec: MessageCodec类型或MethodCodec类型,代表消息的编解码器

除了需要自定义name之外,其余变量用默认值即可

前两种Channel都提供了原生和Flutter互相通信的能力,而FlutterEventChannel不支持Flutter端发送数据,由此可见,它们的应用场合不太一样,接下来我会讲解它们每个的使用方法。

所有的Channel都需要名字,在一个项目中可能会有很多的Channel,每个Channel都应该使用唯一的命名标识,否则可能会被覆盖。当有消息从Flutter端发送到原生端时,会根据其传递过来的名字找到该Channel对应的Handler(消息处理器)
推荐的命名方式是组织名称加插件的名称,例如:com.nakiri.ayame/native_image_view,如果一个插件中包含了多个Channel可再根据功能模块进一步进行区分

FlutterMethodChannel

通过前面我们已经知道Flutter是通过FlutterMethodChannel去调原生方法的,接下来我们在原生端也使用FlutterMethodChannel去调Flutter的方法。
首先我们改造下原生端的插件类,在里面定义一个属性,同时把register方法注册的FlutterMethodChannel赋值给该属性。

 private static var methodChannel: FlutterMethodChannel!
 methodChannel = FlutterMethodChannel(name: "nakiri", binaryMessenger: registrar.messenger())

然后我们就可以通过FlutterMethodChannelinvokeMethod方法调用Flutter的方法了。

// 调用Flutter方法
SwiftNakiriPlugin.methodChannel.invokeMethod("updateNumber", arguments: (number + 1)) { value in
    result(value) // 获取Flutter方法的返回值,并返回给Flutter
}

然后在Flutter端插件代码中增加监听方法:

static int number = 1;

/// 当需要原生调用Flutter方法时,请先调用下初始化方法来增加监听
static void init() {
    /// 设置原生调用Flutter时的回调
    _channel.setMethodCallHandler((call) async {
       switch(call.method) {
         case "updateNumber":
           return _updateNumber(call.arguments); /// 把结果返回给原生端

         default:
           break;
       }
    });
}
  
/// 实现原生调用Flutter方法
static int _updateNumber(int value) {
    return number + value;
}

FlutterBasicMessageChannel

相对于FlutterMethodChannel需要绑定代理,FlutterBasicMessageChannel在处理消息上更为方便灵活,并且能发送大内存数据块的数据。

Flutter端使用FlutterBasicMessageChannel发送数据给原生端

流程如下:

  • 原生端创建FlutterBasicMessageChannel
  • 原生端使用setMessageHandler方法,设置该ChannelMessageHandler回调
  • Flutter端创建该nameBasicMessageChannel
  • Flutter端使用该BasicMessageChannel通过send方法向原生端发送消息
  • 原生端的MessageHandler收到Flutter端发送的消息,在闭包中获取到值,并且可以通过reply闭包给Flutter端回复
  • Flutter端处理该回复

原生端的插件类增加的代码:

private static var basicMessageChannel: FlutterBasicMessageChannel!

// 原生和Flutter互相发送数据
basicMessageChannel = FlutterBasicMessageChannel(name: "flutter_plugin_basic_nakiri", binaryMessenger: registrar.messenger())
        
// 设置消息接收器,用来接收数据;当Flutter端发送消息过来的时候,会自动回调;设置为nil时取消监听
basicMessageChannel.setMessageHandler { value, reply in
    reply(value as! Int + 1) // 使用reply给Flutter端回复消息
}

Flutter端插件只需要增加一个属性即可:

static const BasicMessageChannel basicMessageChannel = BasicMessageChannel("flutter_plugin_basic_nakiri", StandardMessageCodec()); /// 定义一个渠道事件监听;名字需要唯一且各端保持一致

然后在Flutter测试工程里,对原生发送数据:

Nakiri.basicMessageChannel.send(_basicNumber).then((value) {
    setState(() {
        _basicNumber = value;
    });
});

原生端使用FlutterBasicMessageChannel发送数据给Flutter端

流程如下:

  • Flutter端创建BasicMessageChannel
  • Flutter端使用setMessageHandler方法设置该ChannelHandler回调
  • 原生端创建该nameBasicMessageChannel
  • 原生端使用该BasicMessageChannel通过sendMessage方法向Flutter端发送消息
  • Flutter端的Handler收到发送的消息,并处理消息,然后通过return进行回复
  • 原生端处理该回复

原生端的插件类增加的代码:

// 给Flutter发送数据,并等待Flutter端回复
SwiftNakiriPlugin.basicMessageChannel.sendMessage(number + 1) { value in
    result(value)
}

Flutter端插件增加的代码:

/// 设置原生发送消息给Flutter时的回调
basicMessageChannel.setMessageHandler((message) async {
    return message; /// 收到消息后,可以通过return把值回复给原生
});

FlutterEventChannel

由于FlutterEventChannel只能原生给Flutter发送消息,并且无返回值,所以它只能用来传输一些实时数据。
使用FlutterEventChannel步骤如下:

  • Flutter端定义一个EventChannel
  • Flutter端使用receiveBroadcastStream里的listen监听该Channel
  • 原生端定义一个EventChannel
  • 原生端通过setStreamHandler方法设置代理
  • 原生端实现代理的onListenonCancel方法,并在onListen里获取eventSink闭包,在onCancel方法里释放eventSink闭包
  • 原生端使用eventSink闭包方法消息
  • Flutter端接收到消息并处理

原生端增加一个类,用来封装相关逻辑:

class ZTEventChannel: NSObject {
    
    private var eventChannel: FlutterEventChannel?
    
    private var eventSink: FlutterEventSink?
    
    private var timer: Timer?
    
    private var number = 5
    
    override init() {
        super.init()
    }
    
    required convenience init(binaryMessenger messenger: FlutterBinaryMessenger) {
        self.init()
        eventChannel = FlutterEventChannel(name: "flutter_plugin_event_nakiri", binaryMessenger: messenger) // 通道名必须唯一且和各端保持一致
        eventChannel?.setStreamHandler(self)
    }
    
    private func removeTimer() {
        timer?.invalidate()
        timer = nil
        number = 5
    }
    
    private func createTimer() {
        if #available(iOS 10.0, *) {
            if timer == nil {
                timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: { [weak self] (timer) in
                    self?.timerCall()
                })
            }
        }
    }
    
    @objc private func timerCall() {
        if let event = eventSink {
            event(number)
            
            number += 5
        }
    }
}

extension ZTEventChannel: FlutterStreamHandler {
    
    func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        // 在这里获取到eventSink
        self.eventSink = events
        createTimer()
        
        return nil
    }
    
    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        // 在这里移除eventSink
        self.eventSink = nil
        removeTimer()
        
        return nil
    }
}

然后在原生端插件类里使用该类:

// 原生实时发送数据流给Flutter
eventChannel = ZTEventChannel(binaryMessenger: registrar.messenger())

Flutter端插件增加一个属性:

static const EventChannel eventChannel = EventChannel("flutter_plugin_event_nakiri"); /// 定义一个渠道事件监听;名字需要唯一且各端保持一致

然后在别的地方可以使用该属性来接收原生端发来的数据了:

_initStream() {
    /// 监听原生发来的消息
    _stream ??= Nakiri.eventChannel.receiveBroadcastStream().listen((data) {
        /// 这里的data就是原生端发送过来的数据
        setState(() {
            _eventValue = data;
        });
        }, onError: (error) { /// 错误处理
        setState(() {
            _eventValue = -5;
        });
    });
}

此外,还可以暂停接收数据、重新接收数据和移除接收数据:

/// 暂停数据接收
_stream?.pause();

/// 恢复数据流
_stream?.resume();

_removeStream() {
    if (_stream != null) {
        /// 移除监听
        _stream?.cancel();
        _stream = null;
    }
}

移除监听后,想要重新监听时,只需要调用_initStream()方法即可,不需要重新创建eventChannel

插件上传到Pub

上传插件前,需要完善一些资料:

  • README.md介绍包的文件
  • CHANGELOG.md记录每个版本中的更改
  • LICENSE包含软件包许可条款的文件
  • pubspec.yaml的资料
  • 所有公共API的API文档

首先是pubspec.yaml,对Flutter插件来说,pubspec.yaml里除了插件的依赖,还包含一些元信息,根据需要,把这些补上:

name: xxx # 要发布的项目名称
description: xxxxxx. # 项目描述
version: 0.0.1  # 发布的版本
homepage: http://www.github.com/xxx  # 项目主页
issue_tracker: http://www.github.com/xxx # issue,一般写当前插件源代码的Github issue地址
repository: http://www.github.com/xxx.git # 一般写当前插件源代码的Github地址  

另外,发布到Pub上的包需要包含一个LICENSE许可条款文件,不想麻烦的话,可以在GitHub创建仓库的时候选中一个。

发布前检查

我们打开命令行,跳转到pubspec.yaml文件所在的目录,在命令行使用以下命令来测试发布:

flutter packages pub publish --dry-run --server=https://pub.dartlang.org  

之所以使用--server来指定服务器,是因为我们在配置环境的时候,一般都配置了这2个变量:PUB_HOSTED_URL=https://pub.flutter-io.cnFLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn,直接上传会出现问题

如果没有发现问题,如图所示:


无报错.png

然后还需要做的就是上传前的需要清理插件,避免插件过大无法上传:

flutter clean

使用以下命令来发布插件:

flutter packages pub publish --server=https://pub.dartlang.org 

因为是发布到谷歌的平台,所以需要登录谷歌账号进行认证。
在我们输入flutter packages pub publish命令之后,我们会收到一条认证链接,使用浏览器打开链接就可以验证了。

验证.png

我们选择自己的账户,即可开始验证,命令行会自行同步状态,无须我们自己处理的。

开始验证.png

网页出现以下提示,就说明验证成功。

验证成功.png

之后我们只需要等待即可,命令行会自行上传插件到Pub的。
但是,如果遇到这种情况,说明是被墙了,需要使用代理。

失败.png

特别说明下,在代理客户端上开启了代理,并不等于命令行就开启了代理,命令行需要额外开启,具体方法可自行查找。
上传成功后,会出现如下提示:

成功.png

上传成功后,并不会马上能看到,请耐心等待。

插件的使用方式

插件有4种使用方式:

  • pub
  • git
  • 本地
  • 私有pub库

pub依赖

这种是最常见的方式,直接在工程的pubspec.yaml中写上你需要的插件名和版本,之后执行Pub get就行了。

dependencies:
  flutter:
    sdk: flutter
  
  nakiri: ^0.0.1 # 添加库

git依赖

如果我们不想发布到pub,但又想团队共享插件,那么我们可以把库上传到git仓库里面,然后在pubspec.yaml中配置,之后执行Pub get就行了。

dependencies:
  flutter:
    sdk: flutter
    
  nakiri:
    git:
      url: https://github.com/xxx/nakiri.git
      ref: nakiri_fixes_issue_520
      path: packages/nakiri_2
  • url:git地址
  • ref:表示git引用,可以是commit hashtag或者分支
  • path:如果git仓库中有多个软件包,则可以使用此属性指定软件包

本地依赖

上面的方法都需要上传到服务器,较为麻烦,如果只是自己用或者调试插件,那么最好的方式就是本地依赖,只需要在pubspec.yaml中配置路径,之后执行Pub get就行了。

dependencies:
  flutter:
    sdk: flutter

nakiri:
    path: ../xxx/nakiri/

path可以是相对路径,也可以是绝对路径

私有pub仓库依赖

一般而言,pub管理插件比git管理方便,所以一般大公司都会搭建自己的私有pub仓库,依赖私有pub仓库也很简单,只需要在pubspec.yaml中配置完成后,之后执行Pub get就行了。

dependencies:
  flutter:
    sdk: flutter

  nakiri: 
    hosted:
      name: nakiri
      url: http://your-package-server.com
    version: ^0.0.1

依赖覆盖

当2个以上的插件依赖于另一个插件,并且他们所依赖的版本不一致的时候,就可能出现版本冲突,要解决这个冲突,我们可以使用dependency_overrides强制某个插件使用某个版本,如:

dependencies:
  nakiri_2: ^1.0.1
  nakiri: ^0.0.1
dependency_overrides:
  url_launcher: ^5.4.0

这里假设nakiri_2nakiri都依赖url_launcher,但所依赖的版本不一样,通过这种方式,可以让它们都依赖成5.4.0版本。
虽然这种方式可以解决依赖报错,但可能会由于版本的改动使得API接口可能不一样,最终还是可能会出问题,所以,慎用。

相关链接

整个项目已经发到GitHub:demo地址
插件也已经发布到Pub仓库:插件地址
参考资料:Flutter中文网 - Flutter插件教程

iOS OC Swift Flutter开发群 139322447

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

推荐阅读更多精彩内容