乐趣区

关于flutter:FlutterWinChat基于flutter3xbitsdojowindowgetx电脑端仿微信实例

春节期间,吃饱喝足,寻思着年前有应用 flutter3 开发过一款 App 聊天利用,索性应用 flutter3 开发一款桌面端仿微信 exe 聊天我的项目 Flutter_Winchat。

在通过了半个多月的开发,flutter3 桌面聊天我的项目正式开发结束!

在开发的过程中,的确遇到了一些问题,不过好在最初都解决了。

想着这个我的项目还算不错,就和小伙伴们做一些分享。

应用技术

  • 编辑器:vscode
  • 窗口治理:bitsdojo_window: ^0.1.6
  • 托盘图标:system_tray: ^2.0.3
  • 路由 / 状态治理:get: ^4.6.6
  • 本地存储:get_storage: ^2.1.1
  • 图片预览插件:photo_view: ^0.14.0
  • 网址预览:url_launcher: ^6.2.4
  • 视频组件:media_kit: ^1.1.10+1
  • 文件选择器:file_picker: ^6.1.1

整个我的项目应用到了下面的一些技术,窗口治理采纳的 bitsdojo_window 插件,不过 window_manager 这个窗口治理插件也不错,不过绝对重量级一些。

https://pub-web.flutter-io.cn/packages/bitsdojo_window

https://pub-web.flutter-io.cn/packages/window_manager

我的项目构造

通过 flutter create app_proj 创立一个新我的项目。
应用 flutter run -d windows 来运行到 window 桌面。

通过 system_tray 插件治理 flutter 桌面端任务栏托盘图标。

https://pub-web.flutter-io.cn/packages/system_tray

main.dart 入口治理

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:media_kit/media_kit.dart';
import 'package:system_tray/system_tray.dart';

import 'utils/index.dart';

// 引入公共款式
import 'styles/index.dart';

// 引入公共布局模板
import 'layouts/index.dart';

// 引入路由配置
import 'router/index.dart';

void main() async {
  // 初始化 get_storage 存储类
  await GetStorage.init();

  // 初始化 media_kit 视频套件
  WidgetsFlutterBinding.ensureInitialized();
  MediaKit.ensureInitialized();

  initSystemTray();

  runApp(const MyApp());

  // 初始化 bitsdojo_window 窗口
  doWhenWindowReady(() {appWindow.size = const Size(850, 620);
    appWindow.minSize = const Size(700, 500);
    appWindow.alignment = Alignment.center;
    appWindow.title = 'Flutter3-WinChat';
    appWindow.show();});
}

class MyApp extends StatelessWidget {const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      title: 'FLUTTER3 WINCHAT',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primaryColor: FStyle.primaryColor,
        useMaterial3: true,
        // 修改 windows 端字体粗细不统一
        fontFamily: Platform.isWindows ? 'Microsoft YaHei' : null,
      ),
      home: const Layout(),
      // 初始路由
      initialRoute: Utils.isLogin() ? '/index' :'/login',
      // 路由页面
      getPages: routes,
      onInit: () {},
      onReady: () {},
    );
  }
}

// 创立系统托盘图标
Future<void> initSystemTray() async {
  String trayIco = 'assets/images/tray.ico';
  SystemTray systemTray = SystemTray();

  // 初始化系统托盘
  await systemTray.initSystemTray(
    title: 'system-tray',
    iconPath: trayIco,
  );

  // 右键菜单
  final Menu menu = Menu();
  await menu.buildFrom([MenuItemLabel(label: 'show', onClicked: (menuItem) => appWindow.show()),
    MenuItemLabel(label: 'hide', onClicked: (menuItem) => appWindow.hide()),
    MenuItemLabel(label: 'close', onClicked: (menuItem) => appWindow.close()),
  ]);
  await systemTray.setContextMenu(menu);

  // 右键事件
  systemTray.registerSystemTrayEventHandler((eventName) {debugPrint('eventName: $eventName');
    if (eventName == kSystemTrayEventClick) {Platform.isWindows ? appWindow.show() : systemTray.popUpContextMenu();} else if (eventName == kSystemTrayEventRightClick) {Platform.isWindows ? systemTray.popUpContextMenu() : appWindow.show();}
  });
}

flutter3 路由 / 状态治理

采纳 getx 作为路由和状态治理。将 MaterialApp 替换为GetMaterialApp 组件。

class MyApp extends StatelessWidget {const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      title: 'FLUTTER3 WINCHAT',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primaryColor: FStyle.primaryColor,
        useMaterial3: true,
      ),
      home: const Layout(),
      // 初始路由
      initialRoute: Utils.isLogin() ? '/index' :'/login',
      // 路由页面
      getPages: routes,
    );
  }
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';

// 引入工具类
import '../utils/index.dart';

/* 引入路由页面 */
import '../views/auth/login.dart';
import '../views/auth/register.dart';
// 首页
import '../views/index/index.dart';
// 通讯录
import '../views/contact/index.dart';
import '../views/contact/addfriends.dart';
import '../views/contact/newfriends.dart';
import '../views/contact/uinfo.dart';
// 珍藏
import '../views/favor/index.dart';
// 我的
import '../views/my/index.dart';
import '../views/my/setting.dart';
import '../views/my/recharge.dart';
import '../views/my/wallet.dart';
// 朋友圈
import '../views/fzone/index.dart';
import '../views/fzone/publish.dart';
// 短视频
import '../views/fvideo/index.dart';
// 聊天
import '../views/chat/group-chat/chat.dart';

// 路由地址汇合
final Map<String, Widget> routeMap = {'/index': const Index(),
  '/contact': const Contact(),
  '/addfriends': const AddFriends(),
  '/newfriends': const NewFriends(),
  '/uinfo': const Uinfo(),
  '/favor': const Favor(),
  '/my': const My(),
  '/setting': const Setting(),
  '/recharge': const Recharge(),
  '/wallet': const Wallet(),
  '/fzone': const Fzone(),
  '/publish': const PublishFzone(),
  '/fvideo': const Fvideo(),
  '/chat': const Chat(),};

final List<GetPage> patchRoute = routeMap.entries.map((e) => GetPage(
  name: e.key, // 路由名称
  page: () => e.value, // 路由页面
  transition: Transition.noTransition, // 跳转路由动画
  middlewares: [AuthMiddleware()], // 路由中间件
)).toList();

final List<GetPage> routes = [GetPage(name: '/login', page: () => const Login()),
  GetPage(name: '/register', page: () => const Register()),
  ...patchRoute,
];

通过 getx 内置的 middlewares 中间件进行路由跳转拦挡。

// 路由拦挡
class AuthMiddleware extends GetMiddleware {
  @override
  RouteSettings? redirect(String? route) {return Utils.isLogin() ? null : const RouteSettings(name: '/login');
  }
}

flutter 自定义最小化 / 最大化 / 敞开

采纳 bitsdojo_window 插件进行窗口治理。该插件反对去掉零碎导航条,自定义窗口大小、右上角操作按钮、拖拽窗口等性能。

@override
Widget build(BuildContext context){
  return Row(
    children: [
      Container(child: widget.leading,),
      Visibility(
        visible: widget.minimizable,
        child: MouseRegion(
          cursor: SystemMouseCursors.click,
          child: SizedBox(
            width: 32.0,
            height: 36.0,
            child: MinimizeWindowButton(colors: buttonColors, onPressed: handleMinimize,),
          )
        ),
      ),
      Visibility(
        visible: widget.maximizable,
        child: MouseRegion(
          cursor: SystemMouseCursors.click,
          child: SizedBox(
            width: 32.0,
            height: 36.0,
            child: isMaximized ? 
            RestoreWindowButton(colors: buttonColors, onPressed: handleMaxRestore,)
            : 
            MaximizeWindowButton(colors: buttonColors, onPressed: handleMaxRestore,),
          ),
        ),
      ),
      Visibility(
        visible: widget.closable,
        child: MouseRegion(
          cursor: SystemMouseCursors.click,
          child: SizedBox(
            width: 32.0,
            height: 36.0,
            child: CloseWindowButton(colors: closeButtonColors, onPressed: handleExit,),
          ),
        ),
      ),
      Container(child: widget.trailing,),
    ],
  );
}

自定义最大化 / 最小化 / 敞开按钮事件。

// 最小化
void handleMinimize() {appWindow.minimize();
}
// 设置最大化 / 复原
void handleMaxRestore() {appWindow.maximizeOrRestore();
}
// 敞开
void handleExit() {
  showDialog(
    context: context,
    builder: (context) {
      return AlertDialog(content: const Text('是否最小化至托盘,不退出程序?', style: TextStyle(fontSize: 16.0),),
        backgroundColor: Colors.white,
        surfaceTintColor: Colors.white,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(3.0)),
        elevation: 3.0,
        actionsPadding: const EdgeInsets.all(15.0),
        actions: [
          TextButton(onPressed: () {Get.back();
              appWindow.close();},
            child: const Text('退出', style: TextStyle(color: Colors.red),)
          ),
          TextButton(onPressed: () {Get.back();
              appWindow.hide();},
            child: const Text('最小化至托盘', style: TextStyle(color: Colors.deepPurple),)
          ),
        ],
      );
    }
  );
}

通过 flutter 内置的 WidgetsBindingObserver 监听窗口尺寸变动。

class _WinbtnState extends State<Winbtn> with WidgetsBindingObserver {
  // 是否最大化
  bool isMaximized = false;

  @override
  void initState() {super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {WidgetsBinding.instance.removeObserver(this);
    super.dispose();}

  // 监听窗口尺寸变动
  @override
  void didChangeMetrics() {super.didChangeMetrics();
    WidgetsBinding.instance.addPostFrameCallback((_) {setState(() {isMaximized = appWindow.isMaximized;});
    });
  }

  // ...
}

flutter 公共布局模板

整体我的项目借鉴了微信桌面端 UI 界面。

class Layout extends StatefulWidget {
  const Layout({
    super.key,
    this.activitybar = const Activitybar(),
    this.sidebar,
    this.workbench,
    this.showSidebar = true,
  });

  final Widget? activitybar; // 左侧操作栏
  final Widget? sidebar; // 侧边栏
  final Widget? workbench; // 右侧工作面板
  final bool showSidebar; // 是否显示侧边栏

  @override
  State<Layout> createState() => _LayoutState();
}

左侧操作栏,无事件操作区域 MoveWindow 还反对拖动窗口性能。

return Scaffold(backgroundColor: Colors.grey[100],
  body: Flex(
    direction: Axis.horizontal,
    children: [
      // 左侧操作栏
      MoveWindow(
        child: widget.activitybar,
        onDoubleTap: () => {},
      ),
      // 侧边栏
      Visibility(
        visible: widget.showSidebar,
        child: SizedBox(
          width: 270.0,
          child: Container(
            decoration: const BoxDecoration(
              gradient: LinearGradient(
                begin: Alignment.topLeft,
                end: Alignment.bottomRight,
                colors: [Color(0xFFEEEBE7), Color(0xFFEEEEEE)
                ]
              ),
            ),
            child: widget.sidebar,
          ),
        ),
      ),
      // 主体容器
      Expanded(
        child: Column(
          children: [
            WindowTitleBarBox(
              child: Row(
                children: [
                  Expanded(child: MoveWindow(),
                  ),
                  // 右上角操作按钮组
                  Winbtn(
                    leading: Row(
                      children: [IconButton(onPressed: () {}, icon: const Icon(Icons.auto_fix_high), iconSize: 14.0,),
                        IconButton(onPressed: () {setState(() {winTopMost = !winTopMost;});
                          },
                          tooltip: winTopMost ? '勾销置顶' : '置顶',
                          icon: const Icon(Icons.push_pin_outlined),
                          iconSize: 14.0,
                          highlightColor: Colors.transparent, // 点击水波纹色彩
                          isSelected: winTopMost ? true : false, // 是否选中
                          style: ButtonStyle(
                            visualDensity: VisualDensity.compact,
                            backgroundColor: MaterialStateProperty.all(winTopMost ? Colors.grey[300] : Colors.transparent),
                            shape: MaterialStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0))
                            ),
                          ),
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ),
            // 右侧工作面板
            Expanded(
              child: Container(child: widget.workbench,),
            ),
          ],
        ),
      ),
    ],
  ),
);

应用 NavigationRail 组件实现侧边导航性能。该组件反对自定义头部和尾部组件。

@override
Widget build(BuildContext context) {
  return Container(
    width: 54.0,
    decoration: const BoxDecoration(color: Color(0xFF2E2E2E),
    ),
    child: NavigationRail(
      backgroundColor: Colors.transparent,
      labelType: NavigationRailLabelType.none, // all 显示图标 + 标签 selected 只显示激活图标 + 标签 none 不显示标签
      indicatorColor: Colors.transparent, // 去掉选中椭圆背景
      indicatorShape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0),
      ),
      unselectedIconTheme: const IconThemeData(color: Color(0xFF979797), size: 24.0),
      selectedIconTheme: const IconThemeData(color: Color(0xFF07C160), size: 24.0,),
      unselectedLabelTextStyle: const TextStyle(color: Color(0xFF979797),),
      selectedLabelTextStyle: const TextStyle(color: Color(0xFF07C160),),
      // 头部(图像)
      leading: GestureDetector(onPanStart: (details) => {},
        child: Container(margin: const EdgeInsets.only(top: 30.0, bottom: 10.0),
          child: InkWell(child: Image.asset('assets/images/avatar/uimg1.jpg', height: 36.0, width: 36.0,),
            onTapDown: (TapDownDetails details) {
              cardDX = details.globalPosition.dx;
              cardDY = details.globalPosition.dy;
            },
            onTap: () {showCardDialog(context);
            },
          ),
        ),
      ),
      // 尾部(链接)
      trailing: Expanded(
        child: Container(margin: const EdgeInsets.only(bottom: 10.0),
          child: GestureDetector(onPanStart: (details) => {},
            child: Column(
              mainAxisAlignment: MainAxisAlignment.end,
              children: [IconButton(icon: Icon(Icons.info_outline, color: Color(0xFF979797), size: 24.0), onPressed:(){showAboutDialog(context);}),
                PopupMenuButton(icon: const Icon(Icons.menu, color: Color(0xFF979797), size: 24.0,),
                  offset: const Offset(54.0, 0.0),
                  tooltip: '',
                  color: const Color(0xFF353535),
                  surfaceTintColor: Colors.transparent,
                  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)),
                  padding: EdgeInsets.zero,
                  itemBuilder: (BuildContext context) {
                    return <PopupMenuItem>[popupMenuItem('我的私密空间', 0),
                      popupMenuItem('锁定', 1),
                      popupMenuItem('意见反馈', 2),
                      popupMenuItem('设置', 3),
                    ];
                  },
                  onSelected: (value) {switch(value) {
                      case 0:
                        Get.toNamed('/my');
                        break;
                      case 3:
                        Get.toNamed('/setting');
                        break;
                    }
                  },
                ),
              ],
            ),
          ),
        ),
      ),
      selectedIndex: tabCur,
      destinations: [...tabNavs],
      onDestinationSelected: (index) {setState(() {
          tabCur = index;
          if(tabRoute[index] != null && tabRoute[index]?['path'] != null) {Get.toNamed(tabRoute[index]['path']);
          }
        });
      },
    ),
  );
}

flutter3 仿微信朋友圈

@override
Widget build(BuildContext context) {
  return Layout(
    showSidebar: false,
    workbench: CustomScrollView(
      slivers: [
        SliverAppBar(backgroundColor: const Color(0xFF224E7F),
          foregroundColor: Colors.white,
          pinned: true,
          elevation: 0.0,
          expandedHeight: 200.0,
          leading: IconButton(icon: const Icon(Icons.arrow_back,), onPressed: () {Navigator.pop(context);}),
          flexibleSpace: FlexibleSpaceBar(
            title: Row(
              children: <Widget>[ClipOval(child: Image.asset('assets/images/avatar/uimg1.jpg',height: 36.0,width: 36.0,fit: BoxFit.fill)),
                const SizedBox(width: 10.0),
                const Text('Andy', style: TextStyle(fontSize: 14.0)),
              ],
            ),
            titlePadding: const EdgeInsets.fromLTRB(55, 10, 10, 10),
            background: InkWell(child: Image.asset('assets/images/cover.jpg', fit: BoxFit.cover),
              onTap: () {changePhotoAlbum(context);},
            ),
          ),
          actions: <Widget>[IconButton(icon: const Icon(Icons.favorite_border, size: 18,), onPressed: () {}),
            IconButton(icon: const Icon(Icons.share, size: 18,), onPressed: () {}),
            IconButton(icon: const Icon(Icons.add_a_photo, size: 18,), onPressed: () {Get.toNamed('/publish');}),
            const SizedBox(width: 10.0,),
          ],
        ),
        SliverToBoxAdapter(
          child: UnconstrainedBox(
            child: Container(width: MediaQuery.of(context).size.height * 3 / 4,
              decoration: const BoxDecoration(color: Colors.white,),
              child: Column(children: uzoneList.map((item) {
                  return Container(padding: const EdgeInsets.all(15.0),
                    decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFEEEEEE), width: .5)),
                    ),
                    child: Row(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: <Widget>[Image.asset(item['avatar'],height: 35.0,width: 35.0,fit: BoxFit.cover),
                        const SizedBox(width: 10.0),
                        Expanded(
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: <Widget>[Text(item['author'], style: TextStyle(color: Colors.indigo[400])),
                              const SizedBox(height: 2.0),
                              Text(item['content'], style: const TextStyle(color: Colors.black87, fontSize: 15.0)),
                              const SizedBox(height: 10.0),
                              GroupZone(images: item['images']),
                              const SizedBox(height: 10.0),
                              Row(
                                children: <Widget>[Expanded(child: Text(item['time'], style: const TextStyle(color: Colors.grey, fontSize: 12.0)),),
                                  FStyle.iconfont(0xe653, color: Colors.black54, size: 16.0,),
                                ],
                              )
                            ],
                          ),
                        ),
                      ],
                    ),
                  );
                }).toList(),),
            ),
          ),
        ),
      ],
    ),
  );
}

flutter3 短视频模板

短视频模板反对点击播放 / 暂停,底部 mini 播放进度条。

// flutter3 视频播放模块  Q:282310962

Container(width: MediaQuery.of(context).size.height * 9 / 16,
  decoration: const BoxDecoration(color: Colors.black,),
  child: Stack(
    children: [
      // Swiper 垂直滚动区域
      PageView(// 自定义滚动行为(反对桌面端滑动、去掉滚动条槽)
        scrollBehavior: SwiperScrollBehavior().copyWith(scrollbars: false),
        scrollDirection: Axis.vertical,
        controller: pageController,
        onPageChanged: (index) {// 暂停(垂直滑动)
          controller.player.pause();},
        children: [
          Stack(
            children: [
              // 视频区域
              Positioned(
                top: 0,
                left: 0,
                right: 0,
                bottom: 0,
                child: GestureDetector(
                  child: Stack(
                    children: [
                      // 短视频插件
                      Video(
                        controller: controller,
                        fit: BoxFit.cover,
                        // 无管制条
                        controls: NoVideoControls,
                      ),
                      // 播放 / 暂停按钮
                      Center(
                        child: IconButton(onPressed: () {controller.player.playOrPause();
                          },
                          icon: StreamBuilder(
                            stream: controller.player.stream.playing,
                            builder: (context, playing) {
                              return Visibility(
                                visible: playing.data == false,
                                child: Icon(
                                  playing.data == true ? Icons.pause : Icons.play_arrow_rounded,
                                  color: Colors.white70,
                                  size: 50,
                                ),
                              );
                            },
                          ),
                        ),
                      ),
                    ],
                  ),
                  onTap: () {controller.player.playOrPause();
                  },
                ),
              ),
              // 右侧操作栏
              Positioned(
                bottom: 70.0,
                right: 10.0,
                child: Column(
                  children: [// ...],
                ),
              ),
              // 底部信息区域
              Positioned(
                bottom: 30.0,
                left: 15.0,
                right: 80.0,
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [// ...],
                ),
              ),
              // 播放 mini 进度条
              Positioned(
                bottom: 15.0,
                left: 15.0,
                right: 15.0,
                child: Container(// ...),
              ),
            ],
          ),
          Container(
            color: Colors.black,
            child: const Center(child: Text('1', style: TextStyle(color: Colors.white, fontSize: 60),),)
          ),
          Container(
            color: Colors.black,
            child: const Center(child: Text('2', style: TextStyle(color: Colors.white, fontSize: 60),),)
          ),
          Container(
            color: Colors.black,
            child: const Center(child: Text('3', style: TextStyle(color: Colors.white, fontSize: 60),),)
          ),
        ],
      ),
      // 固定 tab 菜单
      Align(
        alignment: Alignment.topCenter,
        child: DefaultTabController(
          length: 3,
          child: TabBar(
            tabs: const [Tab(text: '举荐'),
              Tab(text: '关注'),
              Tab(text: '同城'),
            ],
            tabAlignment: TabAlignment.center,
            overlayColor: MaterialStateProperty.all(Colors.transparent),
            unselectedLabelColor: Colors.white70,
            labelColor: const Color(0xff0091ea),
            indicatorColor: const Color(0xff0091ea),
            indicatorSize: TabBarIndicatorSize.label,
            dividerHeight: 0,
            indicatorPadding: const EdgeInsets.all(5),
          ),
        ),
      ),
    ],
  ),
),

flutter3 聊天模板

聊天模块表情 / 抉择弹窗采纳了 dialog 展现形式。

// 表情弹窗
void showEmojDialog() {updateAnchorOffset(anchorEmojKey);
  showDialog(
    context: context,
    barrierColor: Colors.transparent, // 遮罩通明
    builder: (context) {
      // 解决 flutter 通过 setState 办法无奈更新以后的 dialog 状态
      // dialog 是一个路由页面,实质跟你以后主页面是一样的。在 Flutter 中它是一个新的路由。所以,你应用以后页面的 setState 办法当然是没法更新 dialog 中内容。// 如何更新 dialog 中的内容呢?答案是应用 StatefulBuilder。return StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
          setEmojState = setState;
          return Stack(
            children: [
              Positioned(top: anchorDy - (anchorDy - 100) - 15,
                left: anchorDx - 180,
                width: 360.0,
                height: anchorDy - 100,
                child: Material(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
                  color: Colors.white,
                  elevation: 1.0,
                  clipBehavior: Clip.hardEdge,
                  child: Column(children: renderEmojWidget(),
                  ),
                ),
              )
            ],
          );
        },
      );
    },
  );
}

通过 setState 办法无奈更新以后的 dialog 状态

showDialog 实质上是另一个路由页面,它的性质跟你以后主页面是一样的。在 Flutter 中它是一个新的路由。所以,你应用以后页面的 setState 办法当然是没法更新 dialog 中内容。如何更新 dialog 中的内容呢?当然是应用 StatefulBuilder 组件。

late StateSetter setEmojState;

// 表情 Tab 切换
void handleEmojTab(index) {
    var emols = emoJson;
    for(var i = 0, len = emols.length; i < len; i++) {emols[i]['selected'] = false;
    }
    emols[index]['selected'] = true;
    setEmojState(() {emoJson = emols;});
    emojController.jumpTo(0);
}

整个我的项目涵盖的 flutter 知识点还蛮多的,限于篇幅,不都一一具体介绍了。
心愿以上的分享能给大家些许的帮忙哈~~

https://segmentfault.com/a/1190000044624387
https://segmentfault.com/a/1190000044519351

退出移动版