我们之前一直在做Flutter在移动端的应用,今天这里我们拓展一下Flutter的应用场景,我们将拓展到TV应用的开发上来。我们知道目前的智能电视和机顶盒都是基于Android系统的,所以一般的TV应用开发都是采用Android原生进行开发,Google对Android TV的开发也进行了一些规范和库的制定。当然也有的是采用的B/S架构进行设计的。这里我们将进行尝试Flutter开发TV应用。虽然写出来了,效果也还可以,体验流畅,自动适配,但其中按键监听、焦点处理和焦点框处理比较麻烦。由于Google官方并没有推出Flutter TV应用的SDK,所以我们这里只是给大家拓展下思路。接下来,就分享下其中的技术点。本文将主要介绍:
在进行讲解前,我们先看下Flutter TV开发实现的效果图:
Flutter TV应用开发主要难点
由于Google Flutter官方并没有推出TV版Flutter SDK,所以用Flutter尝试编写TV应用,主要是焦点框和焦点移动、焦点顺序的处理,其他的和手机应用差别不大。按键监听、焦点框和焦点处理比较麻烦,这里我们只是作为研究拓展。
原生Android的控件就默认有焦点的处理属性,直接配置使用即可。还支持指定下一个焦点的id。
//焦点处理
android:focusable="true"
//触摸模式下是否可以点击,可选可不选
android:focusableInTouchMode="true"
Flutter开发TV应用就要自己处理按键监听、焦点和焦点框、焦点移动顺序了,比较的麻烦,处理好了这几个问题,开发起来也就没太大难度了。
不过最新版的Flutter多了一个DefaultFocusTraversal这个类,我们可以进行指定方向自动移动焦点了,相对简单了一些。
Flutter Widget能够监听到我们的遥控器或者手机端的按键事件的前提是这个Widget已经获取了焦点才可以。获取焦点后面会讲到,这里暂时不提了。按键监听需要使用RawKeyboardListener这个Widget,构造方法如下:
const RawKeyboardListener({
Key key,
@required this.focusNode,//焦点结点
@required this.onKey,//按键接收处理事件
@required this.child,//接收焦点的子控件
})
很简单给个例子:
FocusNode focusNode0 = FocusNode();
... ...
RawKeyboardListener(
focusNode: focusNode0,
child: Container(
decoration: getCircleDecoration(color0),
child: Padding(
child: Card(
elevation: 5,
shape: CircleBorder(),
child: CircleAvatar(
child: Text(''),
backgroundImage: AssetImage("assets/icon_tv.png"),
radius: radius,
),
),
padding: EdgeInsets.all(padding),
),
),
onKey: (RawKeyEvent event) {
if (event is RawKeyDownEvent && event.data is RawKeyEventDataAndroid) {
RawKeyDownEvent rawKeyDownEvent = event;
RawKeyEventDataAndroid rawKeyEventDataAndroid = rawKeyDownEvent.data;
print("keyCode: ${rawKeyEventDataAndroid.keyCode}");
switch (rawKeyEventDataAndroid.keyCode) {
case 19: //KEY_UP
FocusScope.of(context).requestFocus(_focusNode);
break;
case 20: //KEY_DOWN
break;
case 21: //KEY_LEFT
FocusScope.of(context).requestFocus(focusNode4);
break;
case 22: //KEY_RIGHT
FocusScope.of(context).requestFocus(focusNode1);
break;
case 23: //KEY_CENTER
break;
case 66: //KEY_ENTER
break;
default:
break;
}
}
},
)
这样就实现了我们的Card Widget监听我们的按键事件,遥控器、手机的按键都能监听到。
Flutter TV的Widget获取焦点的处理通过FocusScope这个Widget处理。主动获取焦点代码如下:
FocusNode focusNode0 = FocusNode();
... ...
//主动获取焦点
FocusScope.of(context).requestFocus(focusNode0);
//自动获取焦点
FocusScope.of(context).autofocus(focusNode0);
这样就可以了进行焦点获取处理了。FocusNode这个类也很重要,负责监听焦点的工作。
焦点的移动我们就是用最新的 DefaultFocusTraversal进行自动指定方向进行搜索下一个焦点:
FocusScope.of(context)
.focusInDirection(TraversalDirection.up);
/*或者像下面这样使用*/
DefaultFocusTraversal.of(context).inDirection(
FocusScope.of(context).focusedChild, TraversalDirection.up);
DefaultFocusTraversal.of(context)
.inDirection(_focusNode, TraversalDirection.right);
支持上下左右四个方向。如果想手动指定下一个焦点是哪个的话,可以像下面这样用:
FocusScope.of(context).requestFocus(focusNode);
有了焦点、按键事件监听,剩下的就是选中的焦点框效果的实现了,主要原理这里使用的是用边框,然后动态设置边框颜色或者边框宽度、边框装饰就实现了焦点框选中显示和隐藏的效果。例如选中后焦点框颜色设置为黄色、未选中时就设置为透明色,通过setState({...})进行刷新页面。
例如我们可以在最外层的Container里设置BoxDecoration进行边框效果的设置实现。
var default_decoration = BoxDecoration(
border: Border.all(width: 3, color: Colors.deepOrange),
borderRadius: BorderRadius.all(
Radius.circular(5),
));
... ...
child: Container(
margin: EdgeInsets.all(8),
decoration: default_decoration,
child: widget.child,
));
最后给大家一个完整的最新的技术方案的例子代码:
代码如下:
/*启动欢迎页*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'ui/tv_page.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
SystemChrome.setEnabledSystemUIOverlays([]);
// 强制横屏
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight
]);
return MaterialApp(
title: 'Flutter TV',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Timer timer;
@override
void initState() {
startTimeout();
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
primary: true,
backgroundColor: Colors.black54,
body: Center(
child: Text(
'芒果TV',
style: TextStyle(
fontSize: 50,
color: Colors.deepOrange,
fontWeight: FontWeight.normal),
),
),
);
}
_toPage() {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => TVPage()),
(route) => route == null,
);
}
//倒计时处理
static const timeout = const Duration(seconds: 3);
startTimeout() {
timer = Timer(timeout, handleTimeout);
return timer;
}
void handleTimeout() {
_toPage();
}
@override
void dispose() {
if (timer != null) {
timer.cancel();
timer = null;
}
super.dispose();
}
}
代码如下:
// 应用首页
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_tv/utils/time_utils.dart';
import 'package:flutter_tv/widgets/tv_widget.dart';
import 'home_page.dart';
import 'list_page.dart';
class TVPage extends StatefulWidget {
@override
State<StatefulWidget> createState() {
SystemChrome.setEnabledSystemUIOverlays([]);
// 强制横屏
SystemChrome.setPreferredOrientations(
[DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
return TVPageState();
}
}
class TVPageState extends State<TVPage> with SingleTickerProviderStateMixin {
TabController _tabController;
Timer timer;
var timeString = TimeUtils.getTime();
bool init = false;
FocusNode focusNodeB0 = FocusNode();
FocusNode focusNodeB1 = FocusNode();
@override
void initState() {
super.initState();
//initialIndex为初始选中第几个,length为数量
_tabController = TabController(initialIndex: 0, length: 8, vsync: this);
// 监听
_tabController.addListener(() {
switch (_tabController.index) {
case 0:
break;
case 1:
break;
}
});
focusNodeB0.addListener(() {
if (focusNodeB0.hasFocus) {
setState(() {
_tabController.animateTo(0);
});
}
});
focusNodeB1.addListener(() {
if (focusNodeB1.hasFocus) {
setState(() {
_tabController.animateTo(1);
});
}
});
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black87,
padding: EdgeInsets.all(30),
child: Scaffold(
appBar: AppBar(
backgroundColor: Colors.black87,
leading: Icon(
Icons.live_tv,
color: Colors.deepOrange,
size: 50,
),
title: Text(
'芒果TV',
style: TextStyle(
fontSize: 30, color: Colors.white, fontStyle: FontStyle.italic),
),
primary: true,
actions: <Widget>[
FlatButton(
child: Text(
'$timeString',
style: TextStyle(color: Colors.white),
),
),
],
// 设置TabBar
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.deepOrange,
labelColor: Colors.deepOrange,
unselectedLabelColor: Colors.white,
tabs: <Widget>[
Tab(
child: TVWidget(
hasDecoration: false,
focusChange: (hasFocus) {
if (hasFocus) {
setState(() {
_tabController.animateTo(0);
});
}
},
child: Text(
'首页',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
requestFocus: true,
),
),
Tab(
child: TVWidget(
hasDecoration: false,
focusChange: (hasFocus) {
if (hasFocus) {
setState(() {
_tabController.animateTo(1);
});
}
},
child: Text(
'精选',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
)),
Tab(
child: TVWidget(
hasDecoration: false,
focusChange: (hasFocus) {
if (hasFocus) {
setState(() {
_tabController.animateTo(2);
});
}
},
onclick: () {
print("点击");
},
child: Text(
'国产',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
)),
Tab(
child: TVWidget(
hasDecoration: false,
focusChange: (hasFocus) {
if (hasFocus) {
setState(() {
_tabController.animateTo(3);
});
}
},
child: Text(
'欧美',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
)),
Tab(
child: TVWidget(
hasDecoration: false,
focusChange: (hasFocus) {
if (hasFocus) {
setState(() {
_tabController.animateTo(4);
});
}
},
child: Text(
'日漫',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
),
Tab(
child: TVWidget(
hasDecoration: false,
focusChange: (hasFocus) {
if (hasFocus) {
setState(() {
_tabController.animateTo(5);
});
}
},
child: Text(
'亲子',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
),
Tab(
child: TVWidget(
hasDecoration: false,
focusChange: (hasFocus) {
if (hasFocus) {
setState(() {
_tabController.animateTo(6);
});
}
},
child: Text(
'少综',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
),
Tab(
child: TVWidget(
focusChange: (hasFocus) {
if (hasFocus) {
setState(() {
_tabController.animateTo(7);
});
}
},
hasDecoration: false,
child: Text(
'分类',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
),
],
),
),
body: TabBarView(
controller: _tabController,
children: <Widget>[
HomePage(),
ListPage(),
HomePage(),
ListPage(),
HomePage(),
ListPage(),
HomePage(),
ListPage(),
],
),
),
);
}
startTimeout() {
timer = Timer.periodic(Duration(minutes: 1), (t) {
setState(() {
timeString = TimeUtils.getTime();
});
});
}
@override
void dispose() {
if (timer != null) {
timer.cancel();
timer == null;
}
super.dispose();
}
}
// TAB页面中的其中一个页面,其他类似
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_tv/widgets/tv_widget.dart';
class HomePage extends StatefulWidget {
const HomePage({
Key key,
@required this.index,
}) : super(key: key);
final int index;
@override
State<StatefulWidget> createState() {
return HomePageState();
}
}
class HomePageState extends State<HomePage> with AutomaticKeepAliveClientMixin {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black87,
child: Row(
children: <Widget>[
Flexible(
child: Column(
children: <Widget>[
_buildItem(0),
_buildItem(1),
_buildItem(2),
],
),
flex: 1,
),
Flexible(
child: Column(
children: <Widget>[
_buildImageItem(3, 2),
Expanded(
flex: 1,
child: Row(
children: <Widget>[
_buildImageItem(4, 1),
_buildImageItem(5, 1),
],
)),
],
),
flex: 4,
),
Flexible(
child: Column(
children: <Widget>[
_buildImageItem(6, 2),
_buildImageItem(7, 1),
],
),
flex: 2,
),
Flexible(
child: Column(
children: <Widget>[
_buildImageItem(8, 2),
_buildImageItem(9, 1),
],
),
flex: 2,
),
],
),
);
}
_buildItem(int index) {
return Expanded(
child: TVWidget(
focusChange: (hasfocus) {},
child: Container(
width: MediaQuery.of(context).size.width,
child: GestureDetector(
child: Card(
elevation: 5,
margin: EdgeInsets.all(0),
color: _colors.elementAt(index),
child: Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_icons.elementAt(index),
_title.elementAt(index),
],
),
),
),
onTap: () {
_click(index);
},
),
)),
flex: 1,
);
}
_buildImageItem(int index, int flex) {
return Expanded(
child: TVWidget(
child: Container(
width: MediaQuery.of(context).size.width,
child: GestureDetector(
child: Card(
elevation: 5,
margin: EdgeInsets.all(0),
color: _colors.elementAt(index),
child: Container(
child: Stack(
alignment: Alignment.bottomLeft,
children: <Widget>[
ClipRRect(
child: Image.asset(
_images.elementAt(index),
fit: BoxFit.fill,
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
),
borderRadius: BorderRadius.all(
Radius.circular(5),
),
),
Container(
width: MediaQuery.of(context).size.width,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_title.elementAt(index),
index == 3
? _des.elementAt(index)
: SizedBox(
height: 0,
),
],
),
color: _colors.elementAt(index).withAlpha(240),
padding: EdgeInsets.all(5),
),
],
),
),
),
onTap: () {
_click(index);
},
),
),
focusChange: (hasfocus) {},
),
flex: flex,
);
}
void _click(int index) {
switch (index) {
case 0:
break;
case 4:
// Navigator.push(context, MaterialPageRoute(builder: (context) {
// return AboutPage();
// }));
break;
}
}
List<Icon> _icons = [
Icon(
Icons.search,
size: 38,
color: Colors.white,
),
Icon(
Icons.history,
size: 38,
color: Colors.white,
),
Icon(
Icons.event,
size: 38,
color: Colors.white,
),
Icon(
Icons.share,
size: 38,
color: Colors.deepPurpleAccent,
),
Icon(
Icons.error_outline,
size: 38,
color: Colors.orange,
),
Icon(
Icons.settings,
size: 38,
color: Colors.red,
)
];
List<String> _images = [
'assets/htpy.jpg',
'assets/htpy.jpg',
'assets/htpy.jpg',
'assets/htpy.jpg',
'assets/agzz.jpg',
'assets/amypj.jpg',
'assets/hmjz.jpg',
'assets/dxflqm.jpg',
'assets/lifeandpi.jpg',
'assets/nanasqc.jpg',
];
List<Color> _colors = [
Colors.red,
Colors.orange,
Colors.green,
Colors.red,
Colors.orange,
Colors.green,
Colors.orange,
Colors.orange,
Colors.orange,
Colors.orange,
];
List<Text> _title = [
Text(
"搜索",
style: TextStyle(color: Colors.white, fontSize: 18),
),
Text(
"历史",
style: TextStyle(color: Colors.white, fontSize: 18),
),
Text(
"专题",
style: TextStyle(color: Colors.white, fontSize: 18),
),
Text(
"环太平洋",
style: TextStyle(color: Colors.white, fontSize: 18),
),
Text(
"阿甘正传",
style: TextStyle(color: Colors.white, fontSize: 18),
),
Text(
"傲慢与偏见",
style: TextStyle(color: Colors.white, fontSize: 18),
),
Text(
"黑猫警长",
style: TextStyle(color: Colors.white, fontSize: 18),
),
Text(
"当幸福来敲门",
style: TextStyle(color: Colors.white, fontSize: 18),
),
Text(
"Life Or PI",
style: TextStyle(color: Colors.white, fontSize: 18),
),
Text(
"哪啊哪啊神去村",
style: TextStyle(color: Colors.white, fontSize: 18),
),
];
List<Text> _des = [
Text(
"非常好看的电影",
style: TextStyle(color: Colors.white, fontSize: 12),
),
Text(
"设置密码锁",
style: TextStyle(color: Colors.white, fontSize: 12),
),
Text(
"吐槽反馈你的想法",
style: TextStyle(color: Color.fromRGBO(162, 162, 162, 1), fontSize: 16),
),
Text(
"非常好看的电影",
style: TextStyle(color: Colors.white, fontSize: 12),
),
Text(
"版本信息",
style: TextStyle(color: Color.fromRGBO(162, 162, 162, 1), fontSize: 16),
),
Text(
"系统相关设置",
style: TextStyle(color: Color.fromRGBO(162, 162, 162, 1), fontSize: 16),
),
Text(
"系统相关设置",
style: TextStyle(color: Color.fromRGBO(162, 162, 162, 1), fontSize: 16),
),
];
@override
// TODO: implement wantKeepAlive
bool get wantKeepAlive => true;
}
封装的核心类:
/*封装的核心焦点处理类*/
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class TVWidget extends StatefulWidget {
TVWidget(
{Key key,
@required this.child,
@required this.focusChange,
@required this.onclick,
@required this.decoration,
@required this.hasDecoration = true,
@required this.requestFocus = false})
: super(key: key);
Widget child;
onFocusChange focusChange;
onClick onclick;
bool requestFocus;
BoxDecoration decoration;
bool hasDecoration;
@override
State<StatefulWidget> createState() {
return TVWidgetState();
}
}
typedef void onFocusChange(bool hasFocus);
typedef void onClick();
class TVWidgetState extends State<TVWidget> {
FocusNode _focusNode;
bool init = false;
var default_decoration = BoxDecoration(
border: Border.all(width: 3, color: Colors.deepOrange),
borderRadius: BorderRadius.all(
Radius.circular(5),
));
var decoration = null;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(() {
if (widget.focusChange != null) {
widget.focusChange(_focusNode.hasFocus);
}
if (_focusNode.hasFocus) {
setState(() {
if (widget.hasDecoration) {
decoration = widget.decoration == null
? default_decoration
: widget.decoration;
}
});
} else {
setState(() {
decoration = null;
});
}
});
}
@override
Widget build(BuildContext context) {
if (widget.requestFocus && !init) {
FocusScope.of(context).requestFocus(_focusNode);
init = true;
}
return RawKeyboardListener(
focusNode: _focusNode,
onKey: (event) {
if (event is RawKeyDownEvent &&
event.data is RawKeyEventDataAndroid) {
RawKeyDownEvent rawKeyDownEvent = event;
RawKeyEventDataAndroid rawKeyEventDataAndroid =
rawKeyDownEvent.data;
print("keyCode: ${rawKeyEventDataAndroid.keyCode}");
switch (rawKeyEventDataAndroid.keyCode) {
case 19: //KEY_UP
// DefaultFocusTraversal.of(context).inDirection(
// FocusScope.of(context).focusedChild, TraversalDirection.up);
FocusScope.of(context)
.focusInDirection(TraversalDirection.up);
break;
case 20: //KEY_DOWN
FocusScope.of(context)
.focusInDirection(TraversalDirection.down);
break;
case 21: //KEY_LEFT
// FocusScope.of(context).requestFocus(focusNodeB0);
FocusScope.of(context)
.focusInDirection(TraversalDirection.left);
// 手动指定下一个焦点
// FocusScope.of(context).requestFocus(focusNode);
break;
case 22: //KEY_RIGHT
// FocusScope.of(context).requestFocus(focusNodeB1);
FocusScope.of(context)
.focusInDirection(TraversalDirection.right);
// DefaultFocusTraversal.of(context)
// .inDirection(_focusNode, TraversalDirection.right);
// if(_focusNode.nextFocus()){
// FocusScope.of(context)
// .focusInDirection(TraversalDirection.right);
// }
break;
case 23: //KEY_CENTER
widget.onclick();
break;
case 66: //KEY_ENTER
widget.onclick();
break;
default:
break;
}
}
},
child: Container(
margin: EdgeInsets.all(8),
decoration: decoration,
child: widget.child,
));
}
}
好了,关于Flutter TV开发就讲解这么多。
在前面实现过一个比较旧的版本的Flutter TV开发,Github项目地址:https://github.com/flutteranddart/flutterTV
新版的技术方案的Flutter TV的Github地址:https://github.com/jaychou2012/flutter_tv
新版的技术方案里面有些细节约束处理并没有仔细处理,细节大家可以进行自己处理下,后续也会更新完善。
这里主要是给大家拓展讲解了Flutter TV的应用开发,拓展一下思路和技术方向,内容很新颖,希望有更好的技术方向可以一起共享和研究学习。
本文原作者: 谭东
原文发布于公众号谭东 jay: https://mp.weixin.qq.com/s/GI5g-zdeRwc8_E2zPN8pMA
Flutter是一款移动应用程序SDK,一份代码可以同时生成iOS和Android两个高性能、高保真的应用程序。Flutter目标是使开发人员能够交付在不同平台上都感觉自然流畅的高性能应用程序。我们兼容滚动行为、排版、图标等方面的差异。
关注flutter已经好久,因为没有发正式版,所以一直也不想过早的躺浑水,但是最近无意中看到几篇文章,再加上美团和咸鱼等app也一直在做灰度测试,所以上周开始看了一下官方文档,地址:https://flutter.io/docs/get-started/install,然后在此做一下总结。
Flutter默认是单线程任务处理的,如果不开启新的线程,任务默认在主线程中处理。和iOS应用很像,在Dart的线程中也存在事件循环和消息队列的概念,但在Dart中线程叫做isolate。
Flutter 1.5 的发布,同期也宣布发布 Flutter for Web 的 Preview 版本,正式开启了 Flutter 的全平台 UI 框架之路。早在年初发布的 Flutter 2019 Roadmap 中,就有提到,会在今年支持移动设备之外的平台,对 Web 的支持,算是完成了一个新的里程碑吧。
Flutter作为一个可移植的UI框架,已经支持现代Web应用开发了!我们很开心已经发布了SDK预览版,这样你可以在Web浏览器里直接运行你的Flutter UI代码。
Flutter 与原生之间的通信依赖灵活的消息传递方式:1,Flutter 部分通过平台通道将消息发送到其应用程序的所在的宿主环境(原生应用)。2,宿主环境通过监听平台通道,接收消息。
Flutter是借鉴React的开发思想实现的,在子组件的插槽上,React有this.props.children,Vue有<slot></slot>。当然Flutter也有类似的Widget,那就是Navigator,不过是以router的形式实现(像<router-view></router-view>)。
这两个技术在当下如何选择,我之前在公众号上的回复是:如果你已经处于一个比较满意的公司,并考虑长期发展,公司并未使用这两个技术,你可以专心钻研公司当下使用的,或者未来将要使用的,这些才能助你在公司步步高升。
本文对比的是 UIWebView、WKWebView、flutter_webview_plugin(在 iOS 中使用的是 WKWebView)的加载速度,内存使用情况。测试网页打开的速度,只需要获取 WebView 在开始加载网页和网页加载完成时的时间戳
用来构建漂亮、定制化应用的跨平台的 UI 框架 Flutter 现在已经支持 Web 开发了。我们很高兴推出了一个预览版的 SDK 可以让开发者直接使用 Flutter UI 和业务逻辑代码构建 web 应用
内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!