在已有项目中集成Flutter

本文翻译自 Add-Flutter-to-existing-apps

在已有项目中继承Flutter

约定:
本文中hostApp翻译成: 宿主App, 主App,主应用程序,在示例中可以理解为MyApp

介绍

正在进行的一项为了更加容易地将Flutter添加到现有应用程序中的工作。可以在Add2App project查看进度。

此页面记录了该工作的当前状态,并将在我们构建必要的工具时进行更新。

最后更新时间为2018年11月26日。

“add2app”支持处于预览状态,目前仅在master分支上可用。

免责

由于Flutter的“Add2App”功能处于预览状态,因此相关的API和工具不稳定且可能会发生变化。

Flutter module项目模板

使用flutter create xxx 创建的含有Flutter/Dart代码的Flutter项目包含非常简单的原生应用程序(单一Activity的Android host和单一ViewController的iOS host)。你可以修改这些主应用程序以满足你的需求并构建它们。

但是,如果你开始使用的是某一平台的现有原生程序,你可能希望将Flutter项目作为某种形式的库包含在该应用程序中。

这就是Flutter module模板提供的内容。执行 flutter create -t module xxx会生成一个Flutter项目,其中包含专为现有原生应用程序使用而设计的一个Android库和一个Cocoapods pod。

Android部分

创建Flutter module

假设你已有的一个Android应用程序some/path/MyApp,并且希望将Flutter项目放在该同级目录下:

$ cd some/path/
$ flutter create -t module my_flutter

这将在some/path/my_flutter/Flutte创建一个Flutter module,其中包含一个lib/main.dart文件作为入口,以及一个.android/的隐藏的子文件夹,它包含了Android库中的模块项目。

(虽然以下不需要,但如果您愿意,可以使用Gradle构建该库:

$ cd .android/
$ ./gradlew flutter:assembleDebug

这会在.android/Flutter/build/outputs/aar/下生成一个flutter-debug.aar归档文件)

为宿主App添加Flutter module的依赖

将Flutter module作为子项目包含在主应用程序的settings.gradle中:

// MyApp/settings.gradle
include ':app'                                     // assumed existing content
setBinding(new Binding([gradle: this]))                                 // new
evaluate(new File(                                                      // new
  settingsDir.parentFile,                                               // new
  'my_flutter/.android/include_flutter.groovy'                          // new
))

通过settings.gradle的绑定和script evaluation允许Flutter module include 自己(如:flutter)和模块所使用的任意Flutter插件(如:package_info, :video_player等)

在你的应用程序中实现引入对Flutter module的依赖:

// MyApp/app/build.gradle
:
dependencies {
  implementation project(':flutter')
  :
}

在Java代码中的Flutter module

使用Flutter module的Java API将Flutter视图添加到主应用程序。可以通过直接使用Flutter.createView:

// MyApp/app/src/main/java/some/package/MainActivity.java
fab.setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View view) {
    View flutterView = Flutter.createView(
      MainActivity.this,
      getLifecycle(),
      "route1"
    );
    FrameLayout.LayoutParams layout = new FrameLayout.LayoutParams(600, 800);
    layout.leftMargin = 100;
    layout.topMargin = 200;
    addContentView(flutterView, layout);
  }
});

也可以创建一个自己处理生命周期FlutterFragment:

// MyApp/app/src/main/java/some/package/SomeActivity.java
fab.setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View view) {
    FragmentTransaction tx = getSupportFragmentManager().beginTransaction();
    tx.replace(R.id.someContainer, Flutter.createFragment("route1"));
    tx.commit();
  }
});

上面我们使用字符串"route1"告诉Dart代码在Flutter视图中显示哪个widget。Flutter module项目模板中的lib/main.dart文件应该switch(或以其他方式解释)提供的路由字符串,也可使用window.defaultRouteName,来确定要创建和传递到哪个widget到runApp。示例:

mport 'dart:ui';
import 'package:flutter/material.dart';

void main() => runApp(_widgetForRoute(window.defaultRouteName));

Widget _widgetForRoute(String route) {
  switch (route) {
    case 'route1':
      return SomeWidget(...);
    case 'route2':
      return SomeOtherWidget(...);
    default:
      return Center(
        child: Text('Unknown route: $route', textDirection: TextDirection.ltr),
      );
  }
}

完全取决于你想要的路由字符串以及如何解释它们。

构建和运行应用程序

构建和运行MyApp的方式与添加Flutter module依赖项之前的方式完全相同,通常使用Android Studio。编辑,调试和分析Android代码也是如此。

热重启/重新加载和调试Dart代码

完整的IDE集成以支持使用混合应用程序的Flutter / Dart代码正在进行中。但目前已经提供了Flutter命令行工具和Dart Observatory Web用户界面。

连接设备或启动模拟器。然后使Flutter CLI工具监听应用程序:

$ cd some/path/my_flutter
$ flutter attach
Waiting for a connection from Flutter on Nexus 5X...

从Android Studio以调试模式启动MyApp。导航到使用Flutter的应用程序区域。然后回到终端,应该看到类似于以下内容的输出:

Done.
Syncing files to device Nexus 5X...                          5.1s

🔥  To hot reload changes while running, press "r". To hot restart (and rebuild state), press "R".
An Observatory debugger and profiler on Nexus 5X is available at: http://127.0.0.1:59556/
For a more detailed help message, press "h". To quit, press "q".

你现在可以编辑my_flutter的Dart代码,按下r终端可以重新加载更改。还可以将上面的URL粘贴到浏览器中,以使用Dart Observatory设置断点,分析内存和其他调试任务。

iOS部分

创建Flutter工程模块

假设已有iOS项目在some/path/MyApp路径上,我们在该同级目录上创建一个Flutter工程模块:

$ cd some/path/
$ flutter create -t module my_flutter

这将会在some/path/my_flutter/目录下创建一个以lib/main.dart为入口的Flutter模块项目。其中隐藏的.ios/子目录中包含了一些Cocopods和帮助类的Ruby脚本。

为宿主App添加Flutter依赖模块

下面的描述是假设你现有的iOS应用程序是通过使用Xcode 10.0且使用Objective-C来创建"Single View App"的项目。如果你现有应用程序具有不同的文件夹结构和/或/ .xcconfig文件,则可以重复使用这些文件,但可能需要相应地调整下面提到的一些相对路径。

假定的文件夹结构如下:

.
├── MyApp
│   ├── MyApp
│   │   ├── AppDelegate.h
│   │   ├── AppDelegate.m
│   │   └── ...
│   ...
└── my_flutter
    ├── .ios
    │   ├── Config
    │   ├── Flutter
    │   ├── Runner
    │   ├── Runner.xcodeproj
    │   └── Runner.xcworkspace
    ├── lib
    │   └── main.dart
    ├── my_flutter.iml
    ├── my_flutter_android.iml
    ├── pubspec.lock
    ├── pubspec.yaml
    └── ..
将Flutter app添加到Podfile中

集成Flutter框架需要使用CocoaPods依赖项管理器。这是因为Flutter框架也需要可用于你可能包含在my_flutter中的任何Flutter插件。

如果需要,请参考cocoapods.org了解如何在开发设备上安装CocoaPods。

如果你的主应用程序(MyApp)已在使用Cocoapods,只需执行以下操作即可与my_flutterapp集成:

1.将以下几行添加到Podfile:

flutter_application_path = 'path/to/flutter_app/'
eval(File.read(File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')), binding)

注意:根据示例的项目结构这里path/to/flutter_app/需要替换为../my_flutter/

2.执行pod install

每当你在some/path/my_flutter/pubspec.yaml中更改了Flutter插件的依赖关系后,你都需要运行flutter packages get来通过podhelper.rb脚本更新some/path/my_flutter的插件列表。然后再在some/path/MyApp目录下运行pod install

podhelper.rb脚本将确保你的插件和Flutter.framework被添加到项目中,并禁用所有target的bitcode选项。

为Dart代码添加构建阶段

MyApp项目的导航器中最顶层,在主视图的左侧选择TARGETS列表中的MyApp,然后选择Build Phases选项卡。单击主视图左上角的+添加新构建阶段。选择New Run Script Phase并展开新添加到构建阶段列表的Run Script

将以下内容粘贴到Shell字段正下方的文本区域中:

"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build
"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" embed

最后,将新构建阶段拖到Target Dependencies phase下面。

你现在可以能够使用⌘B来构建项目。

以上操作如下图:

1.添加RunScript
2.添加构建命令
3.拖动Run Script
幕后操作

如果你因为某些理由要手动执行此操作或调试这些步骤无法正常工作的原因,请参阅以下内容:

  1. Flutter.framework(引擎库)已嵌入到你的app中。这必须与发布类型(debug/profile/release)以及app的架构(arm *,i386,x86_64等)相匹配。Cocoapods将其作为一个框架,并确保它嵌入到你的原生app中。
  2. App.framework (你的Flutter应用程序二进制文件)已嵌入到你的app中。
  3. flutter_assets 文件夹作为资源嵌入 - 它包含字体,图像,并且在某些构建模式下,它还包含引擎在运行时所需的二进制文件。 此文件夹如果有问题可能导致运行时错误,例如“无法运行引擎进行配置”(Could not run engine for configuration) - 通常表示文件夹未嵌入,或者你尝试通过启用AOT的引擎交叉JIT应用程序,反之亦然!
  4. 任何插件都会被添加为Cocoapods依赖,这么做对插件本身来说更加具体。不过从理论上讲,应该可以手动合并它们。
  5. 对项目中的每个target禁用Bitcode。这是与Flutter引擎链接的必要条件。
  6. Generated.xcconfig(包含特定于Flutter的环境变量)包含在Cocoapods生成的release和debug.xcconfig文件中。

构建阶段脚本(xcode_backend.sh)确保你构建的二进制文件与实际位于文件夹中的Dart代码保持同步。

在宿主app中使用FlutterViewController

你应当根据你的宿主app的实际情况进行操作。下面是一个对Xcode 10.0生成的宿主app的空白屏幕示例(SingleViewApp)。

首先声明你的app delegate继承自FlutterAppDelegate

AppDelegate.h

#import <UIKit/UIKit.h>
#import <Flutter/Flutter.h>

@interface AppDelegate : FlutterAppDelegate
@end

这种情况下AppDelegate.m的内容非常简单,除非你的宿主App需要覆盖其他方法:

#import <FlutterPluginRegistrant/GeneratedPluginRegistrant.h> // Only if you have Flutter Plugins

#include "AppDelegate.h"

@implementation AppDelegate

// This override can be omitted if you do not have any Flutter Plugins.
- (BOOL)application:(UIApplication *)application
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
  [GeneratedPluginRegistrant registerWithRegistry:self];
  return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

@end

如果你使用的是Swift,则可以在以下位置执行以下操作AppDelegate.swift:

import UIKit
import Flutter
import FlutterPluginRegistrant // Only if you have Flutter Plugins.

@UIApplicationMain
class AppDelegate: FlutterAppDelegate {

  // Only if you have Flutter plugins.
  override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    GeneratedPluginRegistrant.register(with: self);
    return super.application(application, didFinishLaunchingWithOptions: launchOptions);
  }

}

<
<details>
<summary>如果app delegate已经从其他地方继承,该如何处理? </summary>

需要为你的app delegate 实现 FlutterAppLifeCycleProvider协议例如:

#import <Flutter/Flutter.h>
#import <UIKit/UIKit.h>
#import <FlutterPluginRegistrant/GeneratedPluginRegistrant.h> // Only if you have Flutter Plugins

@interface AppDelegate : UIResponder <UIApplicationDelegate, FlutterAppLifeCycleProvider>
@property (strong, nonatomic) UIWindow *window;
@end

具体实现则应该委托给FlutterPluginAppLifeCycleDelegate


@implementation AppDelegate
{
  FlutterPluginAppLifeCycleDelegate *_lifeCycleDelegate;
}

- (instancetype)init {
  if (self = [super init]) {
      _lifeCycleDelegate = [[FlutterPluginAppLifeCycleDelegate alloc] init];
  }
  return self;
}

- (BOOL)application:(UIApplication*)application
didFinishLaunchingWithOptions:(NSDictionary*)launchOptions {
  [GeneratedPluginRegistrant registerWithRegistry:self]; // Only if you are using Flutter plugins.
  return [_lifeCycleDelegate application:application didFinishLaunchingWithOptions:launchOptions];
}

// Returns the key window's rootViewController, if it's a FlutterViewController.
// Otherwise, returns nil.
- (FlutterViewController*)rootFlutterViewController {
  UIViewController* viewController = [UIApplication sharedApplication].keyWindow.rootViewController;
  if ([viewController isKindOfClass:[FlutterViewController class]]) {
      return (FlutterViewController*)viewController;
  }
  return nil;
}

- (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event {
  [super touchesBegan:touches withEvent:event];
  
  // Pass status bar taps to key window Flutter rootViewController.
  if (self.rootFlutterViewController != nil) {
      [self.rootFlutterViewController handleStatusBarTouches:event];
  }
}

- (void)applicationDidEnterBackground:(UIApplication*)application {
  [_lifeCycleDelegate applicationDidEnterBackground:application];
}

- (void)applicationWillEnterForeground:(UIApplication*)application {
  [_lifeCycleDelegate applicationWillEnterForeground:application];
}

- (void)applicationWillResignActive:(UIApplication*)application {
  [_lifeCycleDelegate applicationWillResignActive:application];
}

- (void)applicationDidBecomeActive:(UIApplication*)application {
  [_lifeCycleDelegate applicationDidBecomeActive:application];
}

- (void)applicationWillTerminate:(UIApplication*)application {
  [_lifeCycleDelegate applicationWillTerminate:application];
}

- (void)application:(UIApplication*)application
didRegisterUserNotificationSettings:(UIUserNotificationSettings*)notificationSettings {
  [_lifeCycleDelegate application:application
didRegisterUserNotificationSettings:notificationSettings];
}

- (void)application:(UIApplication*)application
didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken {
  [_lifeCycleDelegate application:application
didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}

- (void)application:(UIApplication*)application
didReceiveRemoteNotification:(NSDictionary*)userInfo
fetchCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler {
  [_lifeCycleDelegate application:application
     didReceiveRemoteNotification:userInfo
           fetchCompletionHandler:completionHandler];
}

- (BOOL)application:(UIApplication*)application
          openURL:(NSURL*)url
          options:(NSDictionary<UIApplicationOpenURLOptionsKey, id>*)options {
  return [_lifeCycleDelegate application:application openURL:url options:options];
}

- (BOOL)application:(UIApplication*)application handleOpenURL:(NSURL*)url {
  return [_lifeCycleDelegate application:application handleOpenURL:url];
}

- (BOOL)application:(UIApplication*)application
          openURL:(NSURL*)url
sourceApplication:(NSString*)sourceApplication
       annotation:(id)annotation {
  return [_lifeCycleDelegate application:application
                                 openURL:url
                       sourceApplication:sourceApplication
                              annotation:annotation];
}

- (void)application:(UIApplication*)application
performActionForShortcutItem:(UIApplicationShortcutItem*)shortcutItem
completionHandler:(void (^)(BOOL succeeded))completionHandler NS_AVAILABLE_IOS(9_0) {
  [_lifeCycleDelegate application:application
     performActionForShortcutItem:shortcutItem
                completionHandler:completionHandler];
}

- (void)application:(UIApplication*)application
handleEventsForBackgroundURLSession:(nonnull NSString*)identifier
completionHandler:(nonnull void (^)(void))completionHandler {
  [_lifeCycleDelegate application:application
handleEventsForBackgroundURLSession:identifier
                completionHandler:completionHandler];
}

- (void)application:(UIApplication*)application
performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler {
  [_lifeCycleDelegate application:application performFetchWithCompletionHandler:completionHandler];
}

- (void)addApplicationLifeCycleDelegate:(NSObject<FlutterPlugin>*)delegate {
  [_lifeCycleDelegate addDelegate:delegate];
}
@end

</details>

ViewController.m:


#import <Flutter/Flutter.h>
#import "ViewController.h"

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
    [button addTarget:self
               action:@selector(handleButtonAction)
     forControlEvents:UIControlEventTouchUpInside];
    [button setTitle:@"Press me" forState:UIControlStateNormal];
    [button setBackgroundColor:[UIColor blueColor]];
    button.frame = CGRectMake(80.0, 210.0, 160.0, 40.0);
    [self.view addSubview:button];
}

- (void)handleButtonAction {
    FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
    [self presentViewController:flutterViewController animated:false completion:nil];
}
@end

或者,使用Swift:

ViewController.swift:

import UIKit
import Flutter

class ViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
    let button = UIButton(type:UIButtonType.custom)
    button.addTarget(self, action: #selector(handleButtonAction), for: .touchUpInside)
    button.setTitle("Press me", for: UIControlState.normal)
    button.frame = CGRect(x: 80.0, y: 210.0, width: 160.0, height: 40.0)
    button.backgroundColor = UIColor.blue
    self.view.addSubview(button)
  }

  @objc func handleButtonAction() {
    let flutterViewController = FlutterViewController()
    self.present(flutterViewController, animated: false, completion: nil)
  }
}

你现在应该能够在模拟器或设备上构建和启动MyApp。按下按钮会全屏显示带有标准Flutter Demo计数应用程序的Flutter视图。你可以使用路由在应用中的不同位置显示不同的widgets,如上面的Android部分所述。要设置路由,需要调用:

  • Objective-C:
[flutterViewController setInitialRoute:@"route1"];
  • Swift:
flutterViewController.setInitialRoute("route1")

一旦你在创建FlutterViewController之后(并在presenting显示前)。

你可以在Dart代码中通过调用SystemNavigator.pop()让Flutter应用程序消失。

构建和运行应用程序

使用Xcode构建和运行MyApp的方式与添加Flutter模块依赖项之前完全相同。编辑,调试和分析iOS代码也是如此。

热重启/重新加载和调试Dart代码

连接设备或启动模拟器。然后使Flutter CLI工具监听应用程序:

$ cd some/path/my_flutter
$ flutter attach
Waiting for a connection from Flutter on iPhone X...

从Xcode以调试模式启动MyApp。导航到使用Flutter的应用程序区域。然后回到终端,你应该看到类似于以下内容的输出:

Done.
Syncing files to device iPhone 8...                          1.3s

🔥  To hot reload changes while running, press "r". To hot restart (and rebuild
state), press "R".
An Observatory debugger and profiler on iPhone 8 is available at:
http://127.0.0.1:54467/
For a more detailed help message, press "h". To detach, press "d"; to quit,
press "q".

你现在可以编辑my_flutter下的Dart代码,按下r终端可以热重启加载更改。也可以将上面的URL粘贴到浏览器中,以使用Dart Observatory设置断点,分析内存保留和其他调试任务。

调试特定的Flutter实例

可以将多个Flutter(root isolates)实例添加到应用程序中。flutter attach默认情况下连接到所有可用的isolates。然后,从连接的CLI发送的任何命令都会转发到每个连接的isolates。

通过flutterCLI工具键入l来列出所有附加的isolates。如果未指定,则会从dart入口点文件和函数名称自动生成isolates名称。

l同时显示两个Flutter isolates的应用程序的示例输出:

Connected views:
  main.dart$main-517591213 (isolates/517591213)
  main.dart$main-332962855 (isolates/332962855)

通过两个步骤连接到特定的isolates:

1.在其Dart源文件中命名Flutter的根isolate。

// main.dart
import 'dart:ui' as ui;

void main() {
  ui.window.setIsolateDebugName("debug isolate");
  // ...
}

2.通过flutter attach--isolate-filter选项运行。

$ flutter attach --isolate-filter='debug'
Waiting for a connection from Flutter...
Done.
Syncing files to device...      1.1s

🔥  To hot reload changes while running, press "r". To hot restart (and rebuild state), press "R".
An Observatory debugger and profiler is available at: http://127.0.0.1:43343/
For a more detailed help message, press "h". To detach, press "d"; to quit, press "q".

Connected view:
  debug isolate (isolates/642101161)

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

推荐阅读更多精彩内容