fish_redux 入门:助你快速上手 fish_redux 状态管理功能

前言

如果过于依赖思维惯性与经验主义,会在学习与接触新事物时造成很大的阻碍。它可能会蒙蔽我们的认知、减缓我们消化吸收的速度,更有甚者会凭白产生很多根本不存在的对立矛盾来消耗我们的精力,使得我们愈加抵触新事物。
所以,放下过去,以空杯心态去学习、认知新事物,才能更客观全面的了解消化。

fish_redux的相关链接:

Fish Redux

本文适合那些对 Flutter 知识体系有初步了解的朋友,例如

  • Flutter 中 State 是什么? StatelessWidget 与 StatefulWidget 之间的区别是啥?
  • Flutter 的 Navigator 如何进行页面跳转?Flutter 略微复杂的页面开发,包含 UI 更新与数据处理等。
  • 能流程使用 Dart 进行开发,了解 Dart 的异步 Future API。

本文作为 fish_redux 入门文章,并未涉及到 fish_redux 的高级用法。但是它能帮你对 fish_redux 的状态管理、事件分发等有个初步了解,并了解因何而用,如何用。

1、fish_redux

为什么要用 fish_redux ?
一个最简单的使用场景,你在一个 State 中使用耗时操作,例如网络交互、数据库查询等,在 then((){}) 处理回调并调用 setState((){}}) 更新UI,但是在回调时页面处于 deactivate 或者 dispose 状态,结果你的 Flutter 项目报错了,提示如下:

Unhandled Exception: setState() called after dispose(): _MockLeakedState#24ffc(lifecycle state: defunct, not mounted)
This error happens if you call setState() on a State object for a widget that no longer appears in the widget tree (e.g., whose parent widget no longer includes the widget in its build). This error can occur when code calls setState() from a timer or an animation callback.
The preferred solution is to cancel the timer or stop listening to the animation in the dispose() callback. Another solution is to check the "mounted" property of this object before calling setState() to ensure the object is still in the tree.
This error might indicate a memory leak if setState() is being called because another object is retaining a reference to this State object after it has been removed from the tree. To avoid memory leaks, consider breaking the reference to this object during dispose().

引用泄漏或错误状态回调的解决办法有很多,例如在回调中判断 State 状态(State 中的 UI 与 逻辑代码耦合导致,仍然泄露)、监听生命周期及时释放回调等。专业方案有诸如 Provider 、 scoped_model 、 Bloc 等,而 fish_redux 则是最为出色的解决方案之一。在这里顺便推荐一下 flutter_boost 混合开发管理框架,阿里比比比!!!
fish_redux 功能虽然强大,但其 API 与设计思路较为复杂,官方介绍说其延续了前端 Redux 框架的思想。对于很多不熟悉 Redux 的朋友来说,还需要去了解 Redux ,但是框架这种东西没实际用过是很难了解的(需要一定的代码量)。为了帮助不熟悉 fish_redux 的朋友快速上手,于是就有了这篇文章,只要具有前言中提到的对 Flutter 知识体系有一定了解的朋友应该都可以快速上手 fish_redux。

2、<a id="mockLeaked">Mock 泄漏</a>

之后的小节,我会在每处都打个 tag ,在操作处与结尾处都会备注 tag 名称。本小节的 tag 为 mockLeaked

  1. 使用 Flutter 命令创建一个 Application 项目,在 yaml 中依赖fish_redux。
    创建 fish_
    $ flutter create fish_redux_demo
    
    建议使用 IDEA 打开项目,需要装有 Flutter DartFishReduxTemplate 这三个插件。编辑 pubspec.yaml 文件:
    # 建议 sdk 版本 2.6.0 及其以上。可以使用 扩展函数,真香。
    environment:
      sdk: ">=2.6.0 <3.0.0"
    
    dependencies:
      flutter:
        sdk: flutter
    
      # The following adds the Cupertino Icons font to your application.
      # Use with the CupertinoIcons class for iOS style icons.
      cupertino_icons: ^0.1.2
      # 依赖 fish_redx
      fish_redux: ^0.3.3
    
    在 terminal 中输入 flutter pub get 更新项目配置。
  2. 编辑页面,模拟在销毁时遇到的泄漏问题。
    ///--------------------main.dart 代码------------------------------
    import 'package:fish_redux_demo/page/leaked_demo.dart';
    import 'package:flutter/material.dart';
    void main() => runApp(MyApp());
    
    class MyApp extends StatelessWidget {
      // This widget is the root of your application.
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'FishReduxDemo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: MyHomePage(title: 'FishReduxDemo'),
          routes: {
            '/page/mockLeakedPage':(_)=>MockLeakedPage()
          },
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      MyHomePage({Key key, this.title}) : super(key: key);
    
      final String title;
    
      @override
      _MyHomePageState createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      
      void _incrementCounter() {
        Navigator.pushNamed(context, '/page/mockLeakedPage');
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Text(
                  '点击跳转mockLeakedPage',
                ),
              ],
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: _incrementCounter,
            tooltip: 'GoToMockLeakedPage',
            child: Icon(Icons.more_horiz),
          ), // This trailing comma makes auto-formatting nicer for build methods.
        );
      }
    }
    ///--------------------main.dart 代码------------------------------
    ///--------------------main.dart 代码------------------------------
    ///--------------------mock_leaked_demo.dart 代码------------------------------
    import 'dart:async';
    
    import 'package:flutter/material.dart';
    import 'package:flutter/widgets.dart';
    
    class MockLeakedPage extends StatefulWidget {
      @override
      State<StatefulWidget> createState() => _MockLeakedState();
    }
    
    class _MockLeakedState extends State<MockLeakedPage> {
      String _content = "MockLeakedPage";
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text(
              '模拟泄漏',
              style: TextStyle(fontSize: 18),
            ),
            centerTitle: true,
          ),
          body: Container(
            alignment: Alignment(0, 0),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: <Widget>[
                Text(
                  _content,
                  style: TextStyle(fontSize: 16),
                ),
                RaisedButton(
                  child: Text('调用异步函数'),
                  onPressed: () {
                    Timer(Duration(seconds: 3), () {
                      _content = '3秒延时已到';
                      setState(() {});
                    });
                  },
                )
              ],
            ),
          ),
        );
      }
    }
    ///--------------------mock_leaked_demo.dart 代码------------------------------
    
    

运行 App,点击跳到模拟泄漏页面,点击 调用异步函数 按钮立马退出页面,就可以在logcat看到该异常

Unhandled Exception: setState() called after dispose(): _MockLeakedState#24ffc(lifecycle state: defunct, not mounted)
This error happens if you call setState() on a State object for a widget that no longer appears in the widget tree (e.g., whose parent widget no longer includes the widget in its build). This error can occur when code calls setState() from a timer or an animation callback.
The preferred solution is to cancel the timer or stop listening to the animation in the dispose() callback. Another solution is to check the "mounted" property of this object before calling setState() to ensure the object is still in the tree.
This error might indicate a memory leak if setState() is being called because another object is retaining a reference to this State object after it has been removed from the tree. To avoid memory leaks, consider breaking the reference to this object during dispose().

3、<a id="fixLeaked">使用 fish_redux 解决泄漏</a>

本小节 tag 是 fixLeaked
对于不熟悉 fish_redux 的朋友,强烈推荐安装 FishReduxTemplate 插件,用于生成 fish_redux 的相关 API 文件。

  1. 安装好 FishReduxTemplate 插件之后,创建一个文件夹 fixleaked ,右键该文件夹选择 New -> FishReduxTemplate 创建模板文件,选择 Page 类型,输入名称 FixLeaked 。生成文件如下。生成的 dart 文件名称是固定的,但是类名根据输入的名称变化,所以命名请尽量做到见名知意。
    创建fish_redux模板之一

    创建fish_redux模板之二

    创建fish_redux模板之三
  2. 这里暂不介绍这六个文件的作用,直接撸代码。关注数据源 ,编辑 state.dart 文件,FishRedux 要求提供一个 State 类,该类我们可以理解为 MVC 、 MVP 或 MVVM 中的 Model ,它的作用就是承载数据。我们在 FixLeakedState 类中创建一个公开的成员变量 content ,需要注意在 clone 函数中拷贝 FixLeakedState 的成员属性值。该类的 initState(Map<String, dynamic> args) 顶级函数,根据传递的参数创建 FixLeakedState 初始对象来决定页面的初始状态。
    import 'package:fish_redux/fish_redux.dart';
    
    class FixLeakedState implements Cloneable<FixLeakedState> {
      String content;
    
      @override
      FixLeakedState clone() {
        //级联语法给 clone 对象赋值
        return FixLeakedState()..content = this.content;
      }
    }
    
    FixLeakedState initState(Map<String, dynamic> args) {
      return FixLeakedState()..content = "MockLeakedPage";
    }
    
  3. 绘制 UI,修改 view.dart 文件 ,该文件用于创建 Widget 对象,提供一个顶级函数 Widget buildView(FixLeakedState state, Dispatch dispatch, ViewService viewService)。参数列表:
    • FixLeakedStateFishRedux 在合适的时机会重新调用 buildView 函数,我们根据 state 的状态去构造不同 UI 效果即可。至于合适的时机是啥时候,后文再说。
    • Dispatch一个派发函数对象,调用该对象,我们可以分发出不同的事件出去,FishRedux 会在 effect 或者 reducer 中注册监听事件。也就是说我们想把事件从 view 中派发出去,使用 Dispatch 对象就好啦。事件的 API 定义在 action 文件中。
    import 'package:fish_redux/fish_redux.dart';
    
    class FixLeakedState implements Cloneable<FixLeakedState> {
      String content;
    
      @override
      FixLeakedState clone() {
        return FixLeakedState()..content = this.content;
      }
    }
    
    FixLeakedState initState(Map<String, dynamic> args) {
      return FixLeakedState()..content = "MockLeakedPage";
    }
    
    • ViewService:带有 BuildContext 上下文对象的对象。
    • 从该文件:我们就可知,buildView(FixLeakedState, Dispatch, ViewService) 函数通过 state 对象来决定 UI 展示效果,而 Dispatch 对象用于帮助在顶级函数派发事件(Action),ViewService 提供了我们需要用到的 BuildContext 上下文对象。
    import 'package:fish_redux/fish_redux.dart';
    import 'package:flutter/material.dart' hide Action;
    import 'action.dart';
    import 'state.dart';
    
    Widget buildView(FixLeakedState state, Dispatch dispatch, ViewService viewService) {
      return Scaffold(
        appBar: AppBar(
          title: Text(
            '模拟泄漏',
            style: TextStyle(fontSize: 18),
          ),
          centerTitle: true,
        ),
        body: Container(
          alignment: Alignment(0, 0),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              Text(
                state.content,
                style: TextStyle(fontSize: 16),
              ),
              RaisedButton(
                child: Text('调用异步函数'),
                onPressed: () {
                  //dispatch something
                },
              )
            ],
          ),
        ),
      );
    }
    
  4. buildView 我们已经知道通过 Dispatch来分发事件了,那么事件如何定义与创建呢?答案在 action.dart 中。 action.dart 文件定义有两个类,一个枚举类和一个构造器类。
    import 'package:fish_redux/fish_redux.dart';
    
    enum FixLeakedAction { action }
    
    class FixLeakedActionCreator {
      static Action onAction() {
        return const Action(FixLeakedAction.action);
      }
    }
    
    需要什么事件,我们可以定义在枚举类中,构造器类可以传入 dynamic payload 负载来生成对应的枚举对象。
    ///介绍一下 FishRedux 定义的 Action 类,该类看看就行了
    ///需要一个 type 来区分事件,
    ///通过 dynamic 类型的 payload 来传递数据。
    class Action {
      const Action(this.type, {this.payload});
      final Object type;
      final dynamic payload;
    }
    ///实际上 action.dart 编辑后的内容,
    ///删除默认生成的 action 定义之后,注意 effect.dart 与 reduce.dart 使用到了默认 action
    ///注意删除掉它。
    import 'package:fish_redux/fish_redux.dart';
    
    enum FixLeakedAction {
      delay,
      modifyContent,
    }
    
    class FixLeakedActionCreator {
    
      ///创建 delay action,模拟耗时任务
      static Action delay() {
        return const Action(FixLeakedAction.delay);
      }
    
      ///创建修改 content 的 action
      static Action modifyContent(String content) {
        return Action(FixLeakedAction.delay, payload: content);
      }
    }
    
  5. 回到 buildView 方法中,通过按钮 RaisedButton 将模拟延时的事件通过 Dispatch 对象派发出去。
    import 'package:fish_redux/fish_redux.dart';
    import 'package:flutter/material.dart' hide Action;
    
    import 'action.dart';
    import 'state.dart';
    
    Widget buildView(FixLeakedState state, Dispatch dispatch, ViewService viewService) {
      return Scaffold(
        appBar: AppBar(
          title: Text(
            '模拟泄漏',
            style: TextStyle(fontSize: 18),
          ),
          centerTitle: true,
        ),
        body: Container(
          alignment: Alignment(0, 0),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              Text(
                state.content,
                style: TextStyle(fontSize: 16),
              ),
              RaisedButton(
                child: Text('调用异步函数'),
                onPressed: () {
                  dispatch(FixLeakedActionCreator.delay());
                },
              )
            ],
          ),
        ),
      );
    }
    
  6. 事件分发之后,在 effect.dartreducer.dart 来处理事件。 那么 effectreducer 有啥区别勒?
    • effect:直译成 n.影响,作用 vt. 产生;达到目的 等,在此我觉得 翻译为动词产生更符合其定义FishRedux 将 effect 设计为一个 UI 无关的任务触发器,我们可以通过 Action 与其 payload 来进行一些与 State 、buildView 等均毫无关系的工作任务,例如网络交互、数据库读写、IO操作等等。
    • reducer:直译成 n. [助剂] 减速器; 缩减者,减压器,还原剂; ,相信很多朋友刚开始看到这个文件肯定是一脸蒙蔽
      问号脸

      我觉得,在直译这方面,也许只有还原剂能搭上边吧。reducer 接受 Action 的事件之后,会更改 State 对象的状态,而 State 对象的状态变化之后,FishRedux 会触发 buildView 函数,重新构建 UI 。UI 构建时会对比 Widget 对象,所以如果你在这里遇到 UI 没有刷新最好看看生成的新旧 Widget 对象比对结果如何。
    ///--------------effect.dart-------------------
    import 'dart:async';
    
    import 'package:fish_redux/fish_redux.dart';
    import 'action.dart';
    import 'state.dart';
    
    Effect<FixLeakedState> buildEffect() {
      return combineEffects(<Object, Effect<FixLeakedState>>{
        FixLeakedAction.delay: _delay,
      });
    }
    
    void _delay(Action action, Context<FixLeakedState> ctx) {
      Timer(Duration(seconds: 3), () {
        ctx.dispatch(FixLeakedActionCreator.modifyContent('耗时操作结束'));
      });
    }
    ///--------------effect.dart-------------------
    ///--------------reducer.dart-------------------
    import 'package:fish_redux/fish_redux.dart';
    
    import 'action.dart';
    import 'state.dart';
    
    Reducer<FixLeakedState> buildReducer() {
      return asReducer(
        <Object, Reducer<FixLeakedState>>{
          FixLeakedAction.modifyContent: _modifyContent,
        },
      );
    }
    
    FixLeakedState _modifyContent(FixLeakedState state, Action action) {
      return state.clone()..content = action.payload;
    }
    ///--------------reducer.dart-------------------
    
  7. 万事俱备,就等跳转到该页面了,这么多个类是如何联系在一起的呢?
    答案在 page.dart 中,该文件中的 FixLeakedPage 类会把除了 action.dart 文件之外的四个文件串起来,构成一个页面。我们只需要调用 FixLeakedPage().buildPage(args) 生成一个 Widget 对象给 Navigator 跳转即可,这里的 args 暂时传递 null 即可。
    另外在 state.dart 文件中的初始化函数:initState(Map<String, dynamic> args) 就是经由 FixLeakedPage().buildPage(args) 传递赋值的。
    ///--------------page.dart-------------------
    import 'package:fish_redux/fish_redux.dart';
    
    import 'effect.dart';
    import 'reducer.dart';
    import 'state.dart';
    import 'view.dart';
    
    class FixLeakedPage extends Page<FixLeakedState, Map<String, dynamic>> {
      FixLeakedPage()
          : super(
                initState: initState,
                effect: buildEffect(),
                reducer: buildReducer(),
                view: buildView,
                dependencies: Dependencies<FixLeakedState>(
                    adapter: null,
                    slots: <String, Dependent<FixLeakedState>>{
                    }),
                middleware: <Middleware<FixLeakedState>>[
                ],);
    
    }
        ///--------------page.dart-------------------
    
    ///改造一下main.dart文件
    ///--------------main.dart-------------------
    import 'package:fish_redux_demo/page/fixleaked/page.dart';
    import 'package:fish_redux_demo/page/mock_leaked_demo.dart';
    import 'package:flutter/material.dart';
    
    void main() => runApp(MyApp());
    
    class MyApp extends StatelessWidget {
      // This widget is the root of your application.
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'FishReduxDemo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: MyHomePage(title: 'FishReduxDemo'),
          routes: {
            '/page/mockLeakedPage': (_) => MockLeakedPage(),
            '/page/fixLeakedPage': (_) => FixLeakedPage().buildPage(null),
          },
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      MyHomePage({Key key, this.title}) : super(key: key);
    
      final String title;
    
      @override
      _MyHomePageState createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      void _incrementCounter() {
        Navigator.pushNamed(context, '/page/mockLeakedPage');
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Text(
                  '点击跳转mockLeakedPage',
                ),
                RaisedButton(
                  child: Text('跳转 FishRedux FixLeaked 页面'),
                  onPressed: () {
                    Navigator.pushNamed(context, '/page/fixLeakedPage');
                  },
                ),
              ],
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: _incrementCounter,
            tooltip: 'GoToMockLeakedPage',
            child: Icon(Icons.more_horiz),
          ), // This trailing comma makes auto-formatting nicer for build methods.
        );
      }
    }
    ///--------------main.dart-------------------
    

重新运行 App ,无论你咋退出页面,在第2小节中泄漏的问题都不会再泄漏啦。举一反三,在许许多多用到异步的地方,我们使用 fish_redux 就可以愉快搞定他们啦。

注意:effect 中接受的 Action ,在 reducer 中不能接受到。也就是同一个 Action 被 Dispatch 出去,effect 先于 reducer 接受,并且 effect 接受之后不会再向后派发。所以 Action 的定义需要注意消费顺序。同样的,fish_redux 的全局事件派发也有同样的事项需要注意。但这不是本文的重点,之后有时间写 fish_redux 全局状态管理的笔记再注明吧。

4、小结

本文中的源码地址:fish_redux_demo。tag 列表如下:

关于 fish_redux 的更多知识大家可参考下列内容。另外fish_redux 还有很多好用的用法,例如全局状态管理、 Adapter 、middleware 等,本文由于篇幅原因只介绍 fish_redux 的简单应用,力求大家在看完本文章之后能对 fish_redux 的作用与工作流程有个简单的了解。如有错漏,还烦请指出,十分感谢哦!

从我的角度绘制了一下文中 fish_redux Page 对象的创建、渲染、派发 Action 的活动图,本图不涉及到开发中看不见的核心 API,这部分在学习源码之后有空再补充吧。

fish_redux简单活动图

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

推荐阅读更多精彩内容