本文原作者: 谭东,原文发布于公众号谭东 jay: https://mp.weixin.qq.com/s/GI5g-zdeRwc8_E2zPN8pMA

我们之前一直在做 Flutter 在移动端的应用,今天这里我们拓展一下 Flutter 的应用场景,我们将拓展到 TV 应用的开发上来。我们知道目前的智能电视和机顶盒都是基于 Android 系统的,所以一般的 TV 应用开发都是采用 Android 原生进行开发,Google 对 Android TV 的开发也进行了一些规范和库的制定。当然也有的是采用的 B/S 架构进行设计的。这里我们将进行尝试 Flutter 开发 TV 应用。虽然写出来了,效果也还可以,体验流畅,自动适配,但其中按键监听、焦点处理和焦点框处理比较麻烦。由于 Google 官方并没有推出 Flutter TV 应用的 SDK,所以我们这里只是给大家拓展下思路。接下来,就分享下其中的技术点。本文将主要介绍:

  • Flutter TV 应用开发主要难点

  • Flutter TV 应用开发按键监听

  • Flutter TV 应用开发焦点处理

  • Flutter TV 应用开发焦点框效果处理


在进行讲解前,我们先看下 Flutter TV 开发实现的效果图:

Flutter TV 应用开发主要难点

由于 Google Flutter 官方并没有推出 TV 版 Flutter SDK,所以用 Flutter 尝试编写 TV 应用,主要是焦点框和焦点移动、焦点顺序的处理,其他的和手机应用差别不大。按键监听、焦点框和焦点处理比较麻烦,这里我们只是作为研究拓展。
原生 Android 的控件就默认有焦点的处理属性,直接配置使用即可。还支持指定下一个焦点的 id。
//焦点处理
android:focusable="true"
//触摸模式下是否可以点击,可选可不选
android:focusableInTouchMode="true"
Flutter 开发 TV 应用就要自己处理按键监听、焦点和焦点框、焦点移动顺序了,比较的麻烦,处理好了这几个问题,开发起来也就没太大难度了。
不过最新版的 Flutter 多了一个 DefaultFocusTraversal 这个类,我们可以进行指定方向自动移动焦点了,相对简单了一些。

Flutter TV 应用开发按键监听

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 应用开发焦点处理

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);

Flutter TV 应用开发焦点框效果处理

有了焦点、按键事件监听,剩下的就是选中的焦点框效果的实现了,主要原理这里使用的是用边框,然后动态设置边框颜色或者边框宽度、边框装饰就实现了焦点框选中显示和隐藏的效果。例如选中后焦点框颜色设置为黄色、未选中时就设置为透明色,通过 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 的应用开发,拓展一下思路和技术方向,内容很新颖,希望有更好的技术方向可以一起共享和研究学习。

"开发者说·DTalk" 面向中国开发者们征集 Google 移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行谷歌开发技术专家 (GDE) 的推荐。

 点击屏末 |  | 了解更多 "开发者说·DTalk" 活动详情与参与方式

长按右侧二维码

报名参与


Flutter TV 应用的开发尝试 | 开发者说·DTalk相关推荐

  1. 已开源!Flutter 流畅度优化组件 Keframe | 开发者说·DTalk

    本文原作者: Nayuta,原文发布于: 进击的 Flutter 列表流畅度优化 这是一个通用的流畅度优化方案,通过分帧渲染优化由构建导致的卡顿,例如页面切换或者复杂列表快速滚动的场景. 代码中 ex ...

  2. 不一样角度带您了解 Flutter 中的滑动列表实现 | 开发者说·DTalk

    本文原作者: 恋猫de小郭,原‍文发布于: GSYTech 本篇主要帮助剖析理解 Flutter 里的列表和滑动的组成,用比较通俗易懂的方式,从常见的 ListView 到 NestedScrollV ...

  3. 如何理解 Flutter 路由源码设计?| 开发者说·DTalk

    本文原作者: Nayuta,原文发布于: 进击的 Flutter   本期看点: 70 行代码实现一个丐版路由 路由源码细节解析 导语 某天在公众号看到这样一个问题 这问题我熟啊,刚好翻译 Overl ...

  4. 拓展交流空间,分享开发精彩 | 开发者说·DTalk 鉴赏

    日月其迈,岁律更新,时间的洗礼让开发者们更加坚韧,持续探索,不断追求,同样也激励着我们为开发者提供更多的帮助与支持.不断迭代的技术产品是开发者们的趁手工具,定期更新的政策助力打造安全可靠的生态,透彻易 ...

  5. 第 4-2 课:开发一个 Flutter TV 应用

    之前一直在讲 Flutter 在移动端的应用尝试,今天这节内容,我们将拓展到 TV 应用的开发上来. 我们知道目前的智能电视和机顶盒都是基于 Android 系统的,所以一般的 TV 应用开发都是采用 ...

  6. Flutter 混合开发和组件化实践 | 开发者说·DTalk

    本文原作者: 李伟,原文发布于: 印象笔记 https://app.yinxiang.com/fx/0390f0f2-1770-4bdc-a3c4-d134a6bc654b 引言 在接入 Flutte ...

  7. html5 开发tv,乐视TV开放平台鼎力支持html5开发者

    [赛迪网讯]8月14日消息,随着2013年html5峰会圆满召开,乐视对html5开发者的支持进入了一个新的阶段.在过去十年中,互联网为全球范围内的技术创新和开发提供了前所未有的机遇.目前互联网逐渐进 ...

  8. 字节跳动为什么选用 Flutter : 可能成为不一样的未来 | 开发者说·DTalk

    本文原作者: 袁辉辉,原文发布于微信公众号: Gityuan  https://mp.weixin.qq.com/s/SaIAQ22gbB4nJsDQj3QwrQ 2018 年 12 月,Google ...

  9. 谁告诉你 Flutter 会干掉原生开发?

    如下图所示,今天刚好收到一个知乎问题,而刚刚好这个视频我也在抖音上看到过,播放量还不低,本来是"一笑而过"但是既然这么"有缘份",那就关于 "Flut ...

最新文章

  1. 大盘点|6D姿态估计算法汇总(上)
  2. php files上传错误,php-PHP上传文件的问题$_FILES['file']['error']
  3. 升级php影响zabbix吗,zabbix2.0升级到zabbix3.0
  4. 光进铜退下的“更高”与“更低”,锐捷发布企业极简以太全光网解决方案
  5. 触发器-当表1插入数据时将表1的数据插入表2
  6. rust怎么用items刷东西_rust服主怎么刷东西 | 手游网游页游攻略大全
  7. transition.tween
  8. MySQL高可用之MHA的搭建 转
  9. Zookeeper 入门示例
  10. 工作完成了,切勿激动,一定要先求证
  11. 数据结构(C语言版 第2版)课后习题答案 严蔚敏 编著
  12. 线性代数导论20——克莱姆法则、逆矩阵、体积
  13. c语言编程串行静态数码显示实验,十天学会单片机和C语言编程-数码管动态扫描显示.ppt...
  14. html5电商销售网站统计后台模板html5电商销售网站统计后台模板
  15. 对抗神经网络学习和实现(GAN)
  16. 阿尔伯塔大学计算机科学是哪个校区,阿尔伯塔大学优势专业是什么?
  17. css小猫笑起来的动画
  18. HDU 4889 Scary Path Finding Algorithm
  19. APP上查个人信用报告靠谱吗?
  20. 前端框架 — Bootstrap

热门文章

  1. 专家看台:阿里软件产品设计师成长之路
  2. 为什么重写equals方法就一定要重写hashCode方法
  3. 苹果手机一直黑屏转圈_苹果手机电池容量很小,为何一直不做出改变?原因也很明显...
  4. CDA学习-----数据结构
  5. 结巴分词1.8.2版本源代码解析(一)
  6. 华林证券:下半年或重回熊市调整之路
  7. PAT基础编程题目集 7-32 说反话-加强版
  8. 模电——三极管的三种基本组态对比
  9. 高可用架构:CAP原理
  10. 嬴政完美 Windows2000/XP/2003 All in one 2009 贺岁版