Flutter - 探索相机插件

如果您曾经构建或使用过任何大型移动应用程序,则该应用程序很有可能会使用相机功能。如果您查看PlayStore中的热门图表,您会发现许多应用程序都使用相机执行各种任务。Flutter提供了一个相机插件,可以访问Android和iOS设备上的相机。在本文中,我们将探索Flutter相机插件,并且将构建一个小型相机应用程序以查看该插件可以做什么和不能做什么。

在继续前进之前,让我们看看我们将要构建什么。这个应用程式将可以拍照和录制影片。您可以在前置和后置摄像头之间切换。还有一个画廊,您可以在其中查看捕获的图像和录制的视频,并与其他应用程序共享它们或从设备中删除它们。


入门

该应用程序使用以下5个依赖项。您需要将这些依赖项添加到pubspec.yaml

dependencies:
  camera:
  path_provider:
  thumbnails:
    git:
      url: https://github.com/divyanshub024/Flutter_Thumbnails.git
  video_player:
  esys_flutter_share:

接下来,将文件中的最低Android SDK版本更新为21(或更高)android/app/build.gradle

将以下几行添加到您的ios/Runner/Info.plist

<key>NSCameraUsageDescription</key>
<string>Can I use the camera please?</string>
<key>NSMicrophoneUsageDescription</key>
<string>Can I use the mic please?</string>
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <true/>
</dict>

获取可用相机列表

首先,我们将使用相机插件获取相机列表。

List<CameraDescription> _cameras;
@override
void initState() {
  _initCamera();
  super.initState();
}
Future<void> _initCamera() async {
  _cameras = await availableCameras();
}

初始化相机控制器

现在,我们有可用相机的列表。接下来,我们将初始化相机控制器。摄像机控制器用于控制设备摄像机。CameraController接受两个值CameraDescriptionResolutionPreset。最初,我们给出了一个摄像机说明,因为_camera[0]它是我们的后置摄像机。

注意:这里我们ResolutionPreset以介质为准。如果冻结相机,请尝试避免使用更高的分辨率。请查看此问题以获取更多详细信息。

CameraController _controller;

Future<void> _initCamera() async {
  _controller = CameraController(_cameras[0], ResolutionPreset.medium);
  _controller.initialize().then((_) {
    if (!mounted) {
      return;
    }
    setState(() {});
  });
}

@override
void dispose() {
  _controller?.dispose();
  super.dispose();
}

相机预览

设置好相机后,我们将使用CameraPreview小部件显示预览供稿。在显示摄像机预览之前,我们必须等待CameraController初始化。

@override
Widget build(BuildContext context) {
  if (_controller != null) {
    if (!_controller.value.isInitialized) {
      return Container();
    }
  } else {
    return const Center(
      child: SizedBox(
        width: 32,
        height: 32,
        child: CircularProgressIndicator(),
      ),
    );
  }
}

初始化摄像机后,我们将显示摄像机预览。

return Scaffold(
  backgroundColor: Theme.of(context).backgroundColor,
  key: _scaffoldKey,
  extendBody: true,
  body: Stack(
    children: <Widget>[
      _buildCameraPreview(),
    ],
  ),
);

在内部,_buildCameraPreview()我们将摄像机预览缩放到屏幕尺寸,以使其看起来为全屏。

Widget _buildCameraPreview() {
  final size = MediaQuery.of(context).size;
  return ClipRect(
    child: Container(
      child: Transform.scale(
        scale: _controller.value.aspectRatio / size.aspectRatio,
        child: Center(
          child: AspectRatio(
            aspectRatio: _controller.value.aspectRatio,
            child: CameraPreview(_controller),
          ),
        ),
      ),
    ),
  );
}

切换相机

下一步是要能够在前后摄像头之间切换或切换。为此,我们首先将图标按钮添加到stack widget中。

body: Stack(
  children: <Widget>[
    _buildCameraPreview(),
    Positioned(
      top: 24.0,
      left: 12.0,
      child: IconButton(
        icon: Icon(
          Icons.switch_camera,
          color: Colors.white,
        ),
        onPressed: _onCameraSwitch,
      ),
    ),
  ],
),

_onCameraSwitch按下时,此图标按钮调用方法。在此方法中,我们将先处理,CameraController然后使用new初始化CameraController和新的CameraDescription

Future<void> _onCameraSwitch() async {
  final CameraDescription cameraDescription =
      (_controller.description == _cameras[0]) ? _cameras[1] : _cameras[0];
  if (_controller != null) {
    await _controller.dispose();
  }
  _controller = CameraController(cameraDescription, ResolutionPreset.medium);
  _controller.addListener(() {
    if (mounted) setState(() {});
    if (_controller.value.hasError) {
      showInSnackBar('Camera error ${_controller.value.errorDescription}');
    }
  });

  try {
    await _controller.initialize();
  } on CameraException catch (e) {
    _showCameraException(e);
  }

  if (mounted) {
    setState(() {});
  }
}

相机控制视图

在屏幕底部,我们将有一个控件视图,该视图基本上包含3个按钮。首先去画廊,其次去捕捉图像或录制视频,第三次在图像捕捉和视频录制之间切换。

return Scaffold(
  backgroundColor: Theme.of(context).backgroundColor,
  key: _scaffoldKey,
  extendBody: true,
  body: ...
  bottomNavigationBar: _buildBottomNavigationBar(),
);

该视图将显示在底部导航栏中。不要忘记添加extendBody: true.

Widget _buildBottomNavigationBar() {
  return Container(
    color: Theme.of(context).bottomAppBarColor,
    height: 100.0,
    width: double.infinity,
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceAround,
      children: <Widget>[
        FutureBuilder(
          future: getLastImage(),
          builder: (context, snapshot) {
            if (snapshot.data == null) {
              return Container(
                width: 40.0,
                height: 40.0,
              );
            }
            return GestureDetector(
              onTap: () => Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => Gallery(),
                ),
              ),
              child: Container(
                width: 40.0,
                height: 40.0,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(4.0),
                  child: Image.file(
                    snapshot.data,
                    fit: BoxFit.cover,
                  ),
                ),
              ),
            );
          },
        ),
        CircleAvatar(
          backgroundColor: Colors.white,
          radius: 28.0,
          child: IconButton(
            icon: Icon(
              (_isRecordingMode)
                  ? (_isRecording) ? Icons.stop : Icons.videocam
                  : Icons.camera_alt,
              size: 28.0,
              color: (_isRecording) ? Colors.red : Colors.black,
            ),
            onPressed: () {
              if (!_isRecordingMode) {
                _captureImage();
              } else {
                if (_isRecording) {
                  stopVideoRecording();
                } else {
                  startVideoRecording();
                }
              }
            },
          ),
        ),
        IconButton(
          icon: Icon(
            (_isRecordingMode) ? Icons.camera_alt : Icons.videocam,
            color: Colors.white,
          ),
          onPressed: () {
            setState(() {
              _isRecordingMode = !_isRecordingMode;
            });
          },
        ),
      ],
    ),
  );
}

捕获图像

使用相机控制器捕获图像非常容易。

  1. 检查相机控制器是否已初始化。
  2. 构造目录并定义路径。
  3. 使用CameraController捕获图像并将其保存到给定路径。
void _captureImage() async {
  if (_controller.value.isInitialized) {
    final Directory extDir = await getApplicationDocumentsDirectory();
    final String dirPath = '${extDir.path}/media';
    await Directory(dirPath).create(recursive: true);
    final String filePath = '$dirPath/${_timestamp()}.jpeg';
    await _controller.takePicture(filePath);
    setState(() {});
  }
}

录制视频

我们可以将录制视频过程分为两个部分:

开始录像:

  1. 检查相机控制器是否已初始化。
  2. 启动计时器以显示记录的视频时间。(可选的)
  3. 构造目录并定义路径。
  4. 使用摄像机控制器开始录制并将视频保存在定义的路径上。
Future<String> startVideoRecording() async {
  print('startVideoRecording');
  if (!_controller.value.isInitialized) {
    return null;
  }
  setState(() {
    _isRecording = true;
  });
  _timerKey.currentState.startTimer();

  final Directory extDir = await getApplicationDocumentsDirectory();
  final String dirPath = '${extDir.path}/media';
  await Directory(dirPath).create(recursive: true);
  final String filePath = '$dirPath/${_timestamp()}.mp4';

  if (_controller.value.isRecordingVideo) {
    // A recording is already started, do nothing.
    return null;
  }

  try {
    await _controller.startVideoRecording(filePath);
  } on CameraException catch (e) {
    _showCameraException(e);
    return null;
  }
  return filePath;
}

停止录像:

  1. 检查相机控制器是否已初始化。
  2. 停止计时器。
  3. 使用相机控制器停止视频录制。
Future<void> stopVideoRecording() async {
  if (!_controller.value.isRecordingVideo) {
    return null;
  }
  _timerKey.currentState.stopTimer();
  setState(() {
    _isRecording = false;
  });

  try {
    await _controller.stopVideoRecording();
  } on CameraException catch (e) {
    _showCameraException(e);
    return null;
  }
}

这是相机屏幕的完整代码。

import 'dart:io';

import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_camera/gallery.dart';
import 'package:flutter_camera/video_timer.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:thumbnails/thumbnails.dart';

class CameraScreen extends StatefulWidget {
  const CameraScreen({Key key}) : super(key: key);

  @override
  CameraScreenState createState() => CameraScreenState();
}

class CameraScreenState extends State<CameraScreen>
    with AutomaticKeepAliveClientMixin {
  CameraController _controller;
  List<CameraDescription> _cameras;
  final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
  bool _isRecordingMode = false;
  bool _isRecording = false;
  final _timerKey = GlobalKey<VideoTimerState>();

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

  Future<void> _initCamera() async {
    _cameras = await availableCameras();
    _controller = CameraController(_cameras[0], ResolutionPreset.medium);
    _controller.initialize().then((_) {
      if (!mounted) {
        return;
      }
      setState(() {});
    });
  }

  @override
  void dispose() {
    _controller?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    if (_controller != null) {
      if (!_controller.value.isInitialized) {
        return Container();
      }
    } else {
      return const Center(
        child: SizedBox(
          width: 32,
          height: 32,
          child: CircularProgressIndicator(),
        ),
      );
    }

    if (!_controller.value.isInitialized) {
      return Container();
    }
    return Scaffold(
      backgroundColor: Theme.of(context).backgroundColor,
      key: _scaffoldKey,
      extendBody: true,
      body: Stack(
        children: <Widget>[
          _buildCameraPreview(),
          Positioned(
            top: 24.0,
            left: 12.0,
            child: IconButton(
              icon: Icon(
                Icons.switch_camera,
                color: Colors.white,
              ),
              onPressed: () {
                _onCameraSwitch();
              },
            ),
          ),
          if (_isRecordingMode)
            Positioned(
              left: 0,
              right: 0,
              top: 32.0,
              child: VideoTimer(
                key: _timerKey,
              ),
            )
        ],
      ),
      bottomNavigationBar: _buildBottomNavigationBar(),
    );
  }

  Widget _buildCameraPreview() {
    final size = MediaQuery.of(context).size;
    return ClipRect(
      child: Container(
        child: Transform.scale(
          scale: _controller.value.aspectRatio / size.aspectRatio,
          child: Center(
            child: AspectRatio(
              aspectRatio: _controller.value.aspectRatio,
              child: CameraPreview(_controller),
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildBottomNavigationBar() {
    return Container(
      color: Theme.of(context).bottomAppBarColor,
      height: 100.0,
      width: double.infinity,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: <Widget>[
          FutureBuilder(
            future: getLastImage(),
            builder: (context, snapshot) {
              if (snapshot.data == null) {
                return Container(
                  width: 40.0,
                  height: 40.0,
                );
              }
              return GestureDetector(
                onTap: () => Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (context) => Gallery(),
                  ),
                ),
                child: Container(
                  width: 40.0,
                  height: 40.0,
                  child: ClipRRect(
                    borderRadius: BorderRadius.circular(4.0),
                    child: Image.file(
                      snapshot.data,
                      fit: BoxFit.cover,
                    ),
                  ),
                ),
              );
            },
          ),
          CircleAvatar(
            backgroundColor: Colors.white,
            radius: 28.0,
            child: IconButton(
              icon: Icon(
                (_isRecordingMode)
                    ? (_isRecording) ? Icons.stop : Icons.videocam
                    : Icons.camera_alt,
                size: 28.0,
                color: (_isRecording) ? Colors.red : Colors.black,
              ),
              onPressed: () {
                if (!_isRecordingMode) {
                  _captureImage();
                } else {
                  if (_isRecording) {
                    stopVideoRecording();
                  } else {
                    startVideoRecording();
                  }
                }
              },
            ),
          ),
          IconButton(
            icon: Icon(
              (_isRecordingMode) ? Icons.camera_alt : Icons.videocam,
              color: Colors.white,
            ),
            onPressed: () {
              setState(() {
                _isRecordingMode = !_isRecordingMode;
              });
            },
          ),
        ],
      ),
    );
  }

  Future<FileSystemEntity> getLastImage() async {
    final Directory extDir = await getApplicationDocumentsDirectory();
    final String dirPath = '${extDir.path}/media';
    final myDir = Directory(dirPath);
    List<FileSystemEntity> _images;
    _images = myDir.listSync(recursive: true, followLinks: false);
    _images.sort((a, b) {
      return b.path.compareTo(a.path);
    });
    var lastFile = _images[0];
    var extension = path.extension(lastFile.path);
    if (extension == '.jpeg') {
      return lastFile;
    } else {
      String thumb = await Thumbnails.getThumbnail(
          videoFile: lastFile.path, imageType: ThumbFormat.PNG, quality: 30);
      return File(thumb);
    }
  }

  Future<void> _onCameraSwitch() async {
    final CameraDescription cameraDescription =
        (_controller.description == _cameras[0]) ? _cameras[1] : _cameras[0];
    if (_controller != null) {
      await _controller.dispose();
    }
    _controller = CameraController(cameraDescription, ResolutionPreset.medium);
    _controller.addListener(() {
      if (mounted) setState(() {});
      if (_controller.value.hasError) {
        showInSnackBar('Camera error ${_controller.value.errorDescription}');
      }
    });

    try {
      await _controller.initialize();
    } on CameraException catch (e) {
      _showCameraException(e);
    }

    if (mounted) {
      setState(() {});
    }
  }

  void _captureImage() async {
    print('_captureImage');
    if (_controller.value.isInitialized) {
      SystemSound.play(SystemSoundType.click);
      final Directory extDir = await getApplicationDocumentsDirectory();
      final String dirPath = '${extDir.path}/media';
      await Directory(dirPath).create(recursive: true);
      final String filePath = '$dirPath/${_timestamp()}.jpeg';
      print('path: $filePath');
      await _controller.takePicture(filePath);
      setState(() {});
    }
  }

  Future<String> startVideoRecording() async {
    print('startVideoRecording');
    if (!_controller.value.isInitialized) {
      return null;
    }
    setState(() {
      _isRecording = true;
    });
    _timerKey.currentState.startTimer();

    final Directory extDir = await getApplicationDocumentsDirectory();
    final String dirPath = '${extDir.path}/media';
    await Directory(dirPath).create(recursive: true);
    final String filePath = '$dirPath/${_timestamp()}.mp4';

    if (_controller.value.isRecordingVideo) {
      // A recording is already started, do nothing.
      return null;
    }

    try {
//      videoPath = filePath;
      await _controller.startVideoRecording(filePath);
    } on CameraException catch (e) {
      _showCameraException(e);
      return null;
    }
    return filePath;
  }

  Future<void> stopVideoRecording() async {
    if (!_controller.value.isRecordingVideo) {
      return null;
    }
    _timerKey.currentState.stopTimer();
    setState(() {
      _isRecording = false;
    });

    try {
      await _controller.stopVideoRecording();
    } on CameraException catch (e) {
      _showCameraException(e);
      return null;
    }
  }

  String _timestamp() => DateTime.now().millisecondsSinceEpoch.toString();

  void _showCameraException(CameraException e) {
    logError(e.code, e.description);
    showInSnackBar('Error: ${e.code}\n${e.description}');
  }

  void showInSnackBar(String message) {
    _scaffoldKey.currentState.showSnackBar(SnackBar(content: Text(message)));
  }

  void logError(String code, String message) =>
      print('Error: $code\nError Message: $message');

  @override
  bool get wantKeepAlive => true;
}

图库视图

我们的相机已经准备就绪,可以使用了。但是,我们如何查看捕获的图像和录制的视频?我们将创建一个画廊视图。它将由一个水平的网页浏览和一个底部的应用栏以及一个共享按钮和一个删除按钮组成。

在内部,PageView.builder我们正在检查文件的扩展名。如果文件扩展名为jpeg,则将其显示为图像,否则,将使用VideoPreview小部件显示视频。

String currentFilePath;
@override
Widget build(BuildContext context) {
  return Scaffold(
    backgroundColor: Theme.of(context).backgroundColor,
    appBar: AppBar(
      backgroundColor: Colors.black,
    ),
    body: FutureBuilder(
      future: _getAllImages(),
      builder: (context, AsyncSnapshot<List<FileSystemEntity>> snapshot) {
        if (!snapshot.hasData || snapshot.data.isEmpty) {
          return Container();
        }
        print('${snapshot.data.length} ${snapshot.data}');
        if (snapshot.data.length == 0) {
          return Center(
            child: Text('No images found.'),
          );
        }

        return PageView.builder(
          itemCount: snapshot.data.length,
          itemBuilder: (context, index) {
            currentFilePath = snapshot.data[index].path;
            var extension = path.extension(snapshot.data[index].path);
            if (extension == '.jpeg') {
              return Container(
                height: 300,
                padding: const EdgeInsets.only(bottom: 8.0),
                child: Image.file(
                  File(snapshot.data[index].path),
                ),
              );
            } else {
              return VideoPreview(
                videoPath: snapshot.data[index].path,
              );
            }
          },
        );
      },
    ),
    bottomNavigationBar: BottomAppBar(
      child: Container(
        height: 56.0,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: <Widget>[
            IconButton(
              icon: Icon(Icons.share),
              onPressed: () => _shareFile(),
            ),
            IconButton(
              icon: Icon(Icons.delete),
              onPressed: _deleteFile,
            ),
          ],
        ),
      ),
    ),
  );
}

从设备获取媒体文件

Future<List<FileSystemEntity>> _getAllImages() async {
  final Directory extDir = await getApplicationDocumentsDirectory();
  final String dirPath = '${extDir.path}/media';
  final myDir = Directory(dirPath);
  List<FileSystemEntity> _images;
  _images = myDir.listSync(recursive: true, followLinks: false);
  _images.sort((a, b) {
    return b.path.compareTo(a.path);
  });
  return _images;
}

删除媒体文件

删除文件非常容易。只需将目录指向文件路径,然后使用deleteSync函数将其删除。

_deleteFile() {
  final dir = Directory(currentFilePath);
  dir.deleteSync(recursive: true);
  setState(() {});
}

共享媒体文件

为了共享文件,我们使用esys_flutter_share插件。您可以使用Share.file()将String title,String name,List < int >bytes,String mimeType作为强制参数的方法轻松共享文件。您可以使用readAsBytesSync方法从文件中获取字节。

_shareFile() async {
  var extension = path.extension(currentFilePath);
  await Share.file(
    'image',
    (extension == '.jpeg') ? 'image.jpeg' : '  video.mp4',
    File(currentFilePath).readAsBytesSync(),
    (extension == '.jpeg') ? 'image/jpeg' : '  video/mp4',
  );
}

我对相机插件的看法

在得出结论之前,我们应该知道Flutter Camera插件仍在开发中。该插件非常适合制作任何像样的相机应用程序,但是它有一些小问题,并且缺少许多高级功能,例如自动曝光和闪光灯支持。如果您想了解有关相机插件即将发生的变化的最新信息,请关注“相机插件的未来”问题。本期将讨论相机插件中即将提供的一些很酷的功能。


您可以在此处查看该项目的完整源代码

翻译自:https://levelup.gitconnected.com/exploring-flutter-camera-plugin-d2c54ac95f05

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

推荐阅读更多精彩内容