背景

Flutter作为全新跨平台应用框架,在页面渲染和MD开发上优势明显,可谓是业界一枝独秀。正好最近有这样的一个机会学习Flutter开发,我们便尝试用它开发一个MD风格的较复杂页面,来比较跟原生应用开发的优势。也是想通过对新框架的学习探索,找到适合自身应用的框架。

页面展示

首页是整个应用里边交互最为复杂的一个页面了,它集合了各种滑动方式,包括:纵向滑动、横向滑动、嵌套滑动;同时,也集合了各种动效,包括:下拉刷新、上拉加载、头图视差、二级吸顶、回到顶部、横向Banner和纵向News轮播等。

开发历程

  • 搭建了开发环境,新建flutter module并学习dart语法

  • 调研用Flutter实现CoordinatorLayout的方案

  • 实现了首页主框架的demo搭建,目前同样遇到了滑动冲突的问题,在调研解决方案

  • 解决了滑动冲突的问题,并集成了下拉刷新能力

  • 完成了各区块和feed流的静态UI内容,目前剩余feed流加载更多和负二楼动效

  • 实现首页feed流的加载更多功能

技术难点

两级吸顶

在Flutter中实现吸顶功能比较容易,使用SliverPersistentHeader控件或者间接使用该控件都可以满足吸顶的功能;更重要的是,它支持滑动过程中任意组件的吸顶,即多级吸顶功能。

既然多级吸顶都支持,那么两级吸顶就很轻松了,首页头部和feed流tab的两级吸顶是这样实现的:第一级,使用SliverAppBar(它内部就是一个SliverPersistentHeader控件),不仅可以吸顶,还带有折叠属性,折叠属性能更好的满足头部滑动时的动效处理;第二级,使用SliverPersistentHeader并自定义它的delegate,通过pinned属性灵活选择当前模块吸顶与否,这样可以实现任意组件的吸顶功能。

SliverAppBar(pinned: true,...,bottom: PreferredSize(child: Header(...),preferredSize: Size(screenWidth, 15),),),SliverPersistentHeader(pinned: false,delegate: _SliverColumnDelegate(Column(...),)),SliverPersistentHeader(pinned: true,delegate: _SliverTabBarDelegate(TabBar(...)),),

pinned的原理很简单,将它设置为true内容到达顶部后不会再跟随外层的ScrollView继续滚动;反之,内容则会滚动出容器外。

而native端实现这个二级吸顶却很费力,通常你可能需要事先隐藏一个跟吸顶内容一样的驻顶view在那里,然后在页面滚动时计算吸顶内容是否已经划至顶部,维护驻顶view的可见属性达到吸顶效果。

上面粗犷的两级吸顶完成了,但想要充分满足首页的折叠效果和准确的二级吸顶需求,还得深挖一下AppBar内部的折叠计算方法。

SliverAppBar折叠计算

SliverAppBar通常作为页面头部使用,是会随内容一起滑动的一个组件;它的构造方法中有四个Widget类型的参数。分析Widget类型的参数,是因为我们需要一个容器来满足自定义首页头部——它既能实现吸顶,又可以接入自定义组件。

  • leading // 左侧按钮

  • title // 标题

  • flexibleSpace // 可以展开区域

  • bottom // 底部内容区域

回顾一下首页的折叠展示效果,首先排除了leading,因为它的位置大小只是一个按钮的位置,显示比较局限;然后title受leading占位影响宽度有限制也无法满足需要;之后,就剩下两个参数可选了,从命名上看,感觉flexibleSpace更符合折叠效果的实现思路,然后一直在尝试使用其实现头部折叠的需求,但开发过程中发现折叠后的高度是无法达到预期的,最大高度也满足不了设计图给的高度。本来想直接排除法使用起bottom的,但是想到一遇到问题就绕过还是有点SUI。那么想知道为什么flexibleSpace会有高度限制,必然得看一下SliverAppBar的实现源码了。

class _SliverAppBarState extends State<SliverAppBar> with TickerProviderStateMixin {...@overrideWidget build(BuildContext context) {assert(!widget.primary || debugCheckHasMediaQuery(context));final double topPadding = widget.primary ? MediaQuery.of(context).padding.top : 0.0;final double collapsedHeight = (widget.pinned && widget.floating && widget.bottom != null)? widget.bottom.preferredSize.height + topPadding : null;

return MediaQuery.removePadding(context: context,removeBottom: true,child: SliverPersistentHeader(floating: widget.floating,pinned: widget.pinned,delegate: _SliverAppBarDelegate(...collapsedHeight: collapsedHeight,topPadding: topPadding,),),);}}

final double collapsedHeight = (widget.pinned && widget.floating && widget.bottom != null)  ? widget.bottom.preferredSize.height + topPadding : null;

变量collapsedHeight代表了折叠后头部的高度,从它的计算表达式可看出:当widget.bottom == null的时候,collapsedHeight的值为null。换言之,如果不使用bottom,那么折叠高度是没有的。如果没有折叠后的高度会发生什么?这个需要进一步验证。

const double kToolbarHeight = 56.0;

class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {...final double _bottomHeight = bottom?.preferredSize?.height ?? 0.0;@overridedouble get minExtent => collapsedHeight ?? (topPadding + kToolbarHeight + _bottomHeight);}

从上面的源码看,如果collapsedHeight == null,那么折叠后的头部高度就是topPadding + kToolbarHeight了。topPadding是系统状态栏的高度,kToolbarHeight是个自定义常量。不难看出,bottom为空时折叠头部的高度就会是一个固定高度。那么反过来,想要自定义高度,就必须得使用bottom,折叠后的头部高度完全取决于bottom的高度(一般,系统状态栏的高度是确定的)。

你看,不是我们用排除法用了最后一个参数bottom,而是我们分析后知道不用它真得不行。

实现两级吸顶并明确了头部参数设置后,其实整个页面框架就基本拟定了。接下来,我们细化一下,看看头部控件具体怎么实现。

自定义头部

首页头部组件包括以下内容:

  1. 搜索栏和城市名吸顶

  2. 头图视差

基于之前首页native的开发经验,这些效果的实现其实可以由一个变量驱动完成,即首页头部的纵向滑动偏移值。这个偏移值参照它的初始位置,分为上偏移和下偏移。上偏移驱动处理搜索栏和城市名的动效,下偏移则驱动处理头图视差的动效。

通过自定义Header组件来处理搜索栏和城市名吸顶的动画,其中主要是借助外部传入的上偏移值驱动整个动画的完成。

import 'package:flutter/material.dart';import 'package:wuba_flutter_lib/home/search_bar.dart';

const double SEARCH_MARGIN_LEFT = 15.0; // 搜索栏left居左位置

typedef OnOffsetChangeListener = Function(double percent);

class Header extends StatefulWidget {

Header({Key key,this.offset: 0.0,// 外部驱动的偏移属性this.cityName,this.onOffsetChangeListener,}) : super(key: key);

final double offset;final String cityName;final OnOffsetChangeListener onOffsetChangeListener;

double searchLeft    = SEARCH_MARGIN_LEFT;double searchLeftEnd = SEARCH_MARGIN_LEFT;

@overrideState<StatefulWidget> createState() => HeaderState();}

class HeaderState extends State<Header> with TickerProviderStateMixin {

AnimationController searchBgColorAnimController;Animation<Color> searchBgColor;

// 偏移值驱动动画属性drive(offset) {// 过渡比例double percent = offset / 80.0 > 1.0 ? 1.0 : offset / 80.0;// 偏移比例回调if (widget.onOffsetChangeListener != null) {widget.onOffsetChangeListener(percent);}// 搜索栏居左吸顶后的位置widget.searchLeftEnd = SEARCH_MARGIN_LEFT + (widget.cityName ?? '').length * 22 + CITY_MARGIN_RIGHT;// 搜索栏居左位置widget.searchLeft = (SEARCH_MARGIN_LEFT + (widget.searchLeftEnd - SEARCH_MARGIN_LEFT) * percent);// 背景颜色控制searchBgColorAnimController.value = percent;}

@overridevoid didUpdateWidget(Header oldWidget) {super.didUpdateWidget(oldWidget);if (widget.offset != oldWidget.offset) {drive(widget.offset);}}

@overridevoid initState() {super.initState();searchBgColorAnimController = new AnimationController(vsync: this);searchBgColor = ColorTween(begin: Color(0xffffffff),end: Color(0xffDADDE1),).animate(CurvedAnimation(parent: searchBgColorAnimController,curve: Interval(0.0, 1.0, curve: Curves.linear),),);}

@overrideWidget build(BuildContext context) {return Stack(overflow: Overflow.visible,children: <Widget>[// 搜索栏SearchBar(left: widget.searchLeft,bgColor: searchBgColor.value,...),...],);}

}

头图视差 则使用了Container的矩阵变换属性,主要是对y轴进行位移,这个位移加以视差系数便能产生跟Header组件的视差效果。

// 矩阵Matrix4 matrix = Matrix4.translationValues(0.0, _offset/*驱动y轴偏移*/, 0.0);

// 容器Container(transform: matrix,// 矩阵变换width: screenWidth,height: screenWidth,child: Image.asset("assets/images/home_bg.jpg", fit: BoxFit.fill)),

组件化思考

Flutter中分无状态组件StatelessWidget和有状态组件StatefulWidget,React中分无状态组件Stateless Component和高级组件Stateful Component,它们在组件化方面的设计思路是一样的。

组件化 越来越趋向于按状态划分设计,因为这样更贴合实际场景并满足需要。比如首页的区块列表场景中,有一些区块一旦设置后不会再发生状态改变,可理解为无状态的;而另有一些区块初始化后还需要做状态变更,它有了状态,可视为有状态的。无状态的区块和有状态的区块进行组件封装,便成了无状态组件和有状态组件。

首页区块中,无状态的组件主要包括:

  • BigGroup,大类页

  • SmallGroup,小类页

  • LocalNews,同城头条

  • LocalTribe,同城部落

  • BannerAd,广告Banner

有状态的组件目前只有一个:

  • Notification,通知提醒;没有下发通知链接或者请求后台后发现没有通知内容时需要隐藏

如此,按照首页区块的场景,我们便基于无状态组件设计封装了首页无状态组件类HomeStatelessWidget,而基于有状态组件实现了首页有状态组件HomeStatefulWidget

HomeStatelessWidget类封装,内部设有一个容器,然后需要指定它的大小,仅此而已。

abstract class HomeStatelessWidget<T> extends StatelessWidget implements PreferredSizeWidget {

HomeStatelessWidget(this.context, this.key, this.value);

final BuildContext context;final String key;final T value;

Widget get child;double get height;

@overrideSize get preferredSize {// 指定容器大小return Size(MediaQuery.of(context).size.width, height);}

@overrideWidget build(BuildContext context) {return Container(// 容器width: preferredSize.width,height: preferredSize.height,color: Colors.white,alignment: Alignment.centerLeft,child: child,);}}

HomeStatefulWidget类封装,和HomeStatelessWidget类近似,只是多了一个状态类HomeStatefulWidgetState,它用于管理组件的各种状态。

abstract class HomeStatefulWidget<T> extends StatefulWidget implements PreferredSizeWidget {

HomeSizeStatefulWidget(this.context, this.key, this.value);

final BuildContext context;final String key;final T value;

Widget get child;double get height;

void initState(State state) {}

@overrideSize get preferredSize {return Size(MediaQuery.of(context).size.width, height);}

@overrideState<StatefulWidget> createState() => HomeStatefulWidgetState();}

// 状态类class HomeStatefulWidgetState extends State<HomeStatefulWidget> {@overridevoid initState() {super.initState();widget.initState(this);}@overrideWidget build(BuildContext context) {return Container(width: widget.preferredSize.width,height: widget.preferredSize.height,color: Colors.white,alignment: Alignment.centerLeft,child: widget.child,);}}

无状态组件实现起来很容易,只需要给它一次性赋值就可以了,这里不做过多解释。接下来,看看有状态的组件是如何实现的!

通知提醒组件因为需要改变可见性状态,所以要实现首页有状态的组件类HomeStatefulWidget才能满足状态的管理,如下是通知提醒组件的代码实现。

这一点跟native相比,优势还是很明显的。因为native端在view的设计上没有“状态”这个概念,它对状态的概念完全是模糊的。

class Notification extends HomeStatefulWidget<String> {

// 状态字段,当通知内容为空时控制当前组件是否可见bool isContentEmpty = true;

Notification(BuildContext context, String key, String value) : super(context, key, value);

@overridevoid initState(State<StatefulWidget> state) {super.initState(state);// 如果url不为空,则请求通知接口数据if (!isUrlEmpty()) {HomeDataManager.getNotification(value).then((object) {// 获取到通知数据,改变组件的可见性状态state.setState(() {isContentEmpty = object == null;});});}}

@overrideWidget get child => isUrlEmpty() || isContentEmpty ? Container() : Center(child: Text(value));

/// 如果url为空,或是通知接口返回的内容为空,则隐藏自己;/// 否则,显示自己。@overridedouble get height => isUrlEmpty() || isContentEmpty ? 0 : 40; 

// 判断传入的url是否为空bool isUrlEmpty() => (value == null || '' == value);

}

滑动冲突

Android中,只要两个“轮子”有嵌套关系,那么势必存在滑动冲突的问题。要解决嵌套滑动冲突,就只能允许一个轮子驱动,而另一个轮子被带动;而不是两个轮子同时驱动

首页中存在两级冲突问题,也就是说有两层嵌套关系。一,下拉刷新和首页主体;二,首页主体和feed流内容。这相当于有三个轮子存在相互嵌套的关联,如何解决三个轮子的滑动冲突问题,这里有三种思路:

  1. 由一个轮子驱动,另外两个轮子同步被带动;

  2. 由一个轮子驱动,另一个轮子被带动,还有一个轮子卸载;

  3. 由一个轮子先驱动,到达某个位置后转换为另一个轮子驱动,然后剩下的两个轮子跟1和2情况。

三种思路其实都是将三个轮子的嵌套关系进行了降维处理,本质上都在解决两个轮子的冲突问题;总之,核心思想是不能出现两个轮子同时驱动。

NestedScrollViewRefreshIndicator(// 下拉刷新child: ExtendedNestedScrollView(// 首页主体keepOnlyOneInnerNestedScrollPositionActive: true,headerSliverBuilder: (c, f) {return <Widget>[SliverAppBar(),          // 头部搜索SliverPersistentHeader(),// 区块列表SliverPersistentHeader(),// feed流TabBar];},body: TabBarView(// feed流内容children: [Container(child: ListView(),// 推荐), Container(child: ListView(),// 家乡), Container(child: ListView(),// 部落 ), ],),),)

首页主体控件使用了NestedScrollView的扩展类ExtendedNestedScrollView,前者允许嵌套滚动,但是对子视图的高度有要求——确定的高度。做过feed流的开发都知道,它的高度并不好计算,因为模板类型不同对应各自的高度不等,加以本身又可以无限加载扩展,高度一直在变化计算起来难度很大。基于NestedScrollView的扩展类ExtendedNestedScrollView解决了这个痛点,在不依赖子视图高度的情况下同样能够满足嵌套滚动。

解决滑动冲突问题,离不开它的这个重要属性keepOnlyOneInnerNestedScrollPositionActive,直译是仅保活一个内部嵌套的滚动位置,意译便是仅允许一个内部嵌套的视图滚动,即仅允许一个轮子驱动。

if (widget.keepOnlyOneInnerNestedScrollPositionActive) {///get notifications and compute active one in _innerController.nestedPositionsbody = NotificationListener<ScrollNotification>(onNotification: (ScrollNotification notification) {if (((notification is ScrollEndNotification) ||(notification is UserScrollNotification &&notification.direction == ScrollDirection.idle)) &&notification.metrics is PageMetrics &&notification.metrics.axis == Axis.horizontal) {_coordinator._innerController._computeActivatedNestedPosition(notification);}return false;},child: body);}

这里的条件判断计算,其实已经能看出来了,它是实现了上面思路3的做法。此时,首页的两级嵌套滚动冲突解决方案其实已经浮出水面了,只剩下最后一个轮子的处理了,具体是使用情况1还是情况2呢?

ScrollController _scrollController = ScrollController();

_scrollListener() {setState(() {_offset = _scrollController.offset;});}

@overridevoid initState() {super.initState();_scrollController.addListener(_scrollListener);}

NestedScrollViewRefreshIndicator(// 下拉刷新// _offset > 0.0 表示头部上移动,这时候禁止notification事件处理notificationPredicate: (notification) => _offset == 0.0,child: ExtendedNestedScrollView(// 首页主体controller: _scrollController,keepOnlyOneInnerNestedScrollPositionActive: true,...),)

这个问题其实不是一个单选,具体在应用场景中,最终两者都有用到。下滑到达顶部,此时需要触发下拉刷新操作,随即下拉刷新模块被带动,那么就实现了思路1的做法;而其他位置的滑动,则不会触发这个操作,所以可以理解为将其暂时卸载,那么就有了思路2的做法。整体首页的实现,其实是综合应用了这三种思路。

下拉刷新

  • 下拉高度限制

  • 负二楼 // TODO

默认的下拉刷新组件在下拉时可以一直往下,没有对滑动距离做限制,而首页要求下拉至头图完整出现后不再滚动。这个特定的需求RefreshIndicator并不能满足,需要改动一下这个组件才可以。

class NestedScrollViewRefreshIndicator extends StatefulWidget {final OnOffsetCallback onOffset;// 下拉偏移量回调final double offsetLimit;// 下拉偏移的限制值const NestedScrollViewRefreshIndicator({this.onOffset,this.offsetLimit = 0.0,...});}

class NestedScrollViewRefreshIndicatorStateextends State<NestedScrollViewRefreshIndicator>with TickerProviderStateMixin<NestedScrollViewRefreshIndicator> {

AnimationController _positionController;AnimationController _scaleController;

Animation<RelativeRect> _positionRect;Animation<RelativeRect> _positionRectDown;Animation<RelativeRect> _positionRectUp;

Animatable _headerPositionTweenDown;Animatable _headerPositionTweenUp;

double _headerOffset = 0.0;// 头部偏移值

@overridevoid initState() {super.initState();_headerPositionTweenDown = RelativeRectTween(begin: RelativeRect.fromLTRB(0.0, 0.0, 0.0, 0.0),end: RelativeRect.fromLTRB(0.0, widget.offsetLimit, 0.0, 0.0));

_positionController = AnimationController(vsync: this);_scaleController = AnimationController(vsync: this);

_positionRectDown = _positionController.drive(_headerPositionTweenDown);_positionRect = _mode != _RefreshIndicatorMode.done ? _positionRectDown : _positionRectUp;

if (widget.onOffset != null) {_positionController.addListener(() {_headerOffset = _positionController.value;widget.onOffset(_headerOffset);double value = widget.offsetLimit * _headerOffset;_headerPositionTweenUp = RelativeRectTween(begin: RelativeRect.fromLTRB(0.0, value, 0.0, 0.0),end: RelativeRect.fromLTRB(0.0, 0, 0.0, 0.0));_positionRectUp = _scaleController.drive(_headerPositionTweenUp);});_scaleController.addListener(() {double value = (1.0 - _scaleController.value) * _headerOffset;widget.onOffset(value);});}}

setPositionRect(newMode) {setState(() {_positionRect = newMode != _RefreshIndicatorMode.done ? _positionRectDown : _positionRectUp;});}

// _RefreshIndicatorMode.dragbool _handleScrollNotification(ScrollNotification notification) {...setPositionRect(_RefreshIndicatorMode.drag);return false;}

// _RefreshIndicatorMode.canceled || _RefreshIndicatorMode.doneFuture<void> _dismiss(_RefreshIndicatorMode newMode) async {...setPositionRect(newMode);switch (newMode) {case _RefreshIndicatorMode.done:await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);break;case _RefreshIndicatorMode.canceled:await _positionController.animateTo(0.0, duration: _kIndicatorScaleDuration);break;default:assert(false);}}

// _RefreshIndicatorMode.refreshvoid _show() {..._positionController.animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration).then<void>((void value) {setPositionRect(_RefreshIndicatorMode.refresh);});}

@overrideWidget build(BuildContext context) {return Stack(children: <Widget>[// child, // 改动前PositionedTransition(// 改动后rect: _positionRect,child: child,),...],);}}

以上便是摘出的改动了下拉刷新控件的代码逻辑,主要是通过位置动画限定了首页主体向下滚动的最大位移。同时,通过动画偏移的计算,向外输出了头部偏移的值,以便于外部通过监听这个偏移值做更多的动效处理;比如:搜索框、天气、城市、头部、扫码、背景图等头部元素的动画处理。

负二楼的效果实现其实并不复杂,能理解如何通过动画原理改动下拉刷新控件从而实现个性化的动效,那么实现负二楼的效果就是个举一反三的事情。

加载更多

加载更多的原理其实跟native的思路是一样的——判断列表滚动到最末位置触发特定事件。之前native的做法就是判断RecyclerView滑动到最后一项时向feed流最末位置插入一个特定的动画模板,等加载结束后再把这个模板去掉,然后把请求到的内容添加到视图列表中去,这样列表组件就拥有了一个加载更多的能力。

ExtendedNestedScrollView的改动:

double nestOffset(double value, _NestedScrollPosition target) {// 滑动到小于50的时候触发加载更多事件if (target.extentAfter < 50) {_onLoadMore();}}

总结

这样,一个由Flutter开发的首页就已经基本落地了。整个开发过程总结下来,有这样几点可以分享:

  1. 用Flutter和Android开发首页,都依赖了MD组件,它们对此支持得都比较完善;由于Dart语言的特性,Flutter在使用这些组件时更容易扩展、灵活性更强。

  2. Flutter状态化的组件管理机制,显得比Android更切合场景,在区块列表的设计上得心应手,这点也是众多前端框架的亮点。

  3. Flutter的动画设计api很丰富,能充分满足各种UI动效,让页面开发更轻松且不复杂。

  4. Flutter表达性更强,又加以跨平台的解决方案,减少了代码量并大大提升了开发效率,为应用开发起到了开源节流的作用。

  5. Flutter作为新秀,在Java老大哥已经烂熟于MVP等模式设计后,Flutter在此方面还需要积累;也可能Flutter本身并不需要这样的积累,它等待的是比Java中更好的开发模式。

参考文档

Flutter扩展NestedScrollView

flutter offset_用Flutter实现58App的首页相关推荐

  1. flutter刷新页面_用Flutter实现58App的首页

    背景 Flutter作为全新跨平台应用框架,在页面渲染和MD开发上优势明显,可谓是业界一枝独秀.正好最近有这样的一个机会学习Flutter开发,我们便尝试用它开发一个MD风格的较复杂页面,来比较跟原生 ...

  2. 【Flutter】Flutter 拍照示例 ( Flutter 插件配置 | Flutter 插件源码示例 | iOS 应用配置 | Android 应用配置 )

    文章目录 一.Flutter 插件配置 二.Flutter 插件源码示例 三.iOS 应用配置 四.Android 应用配置 五.相关资源 一.Flutter 插件配置 Flutter 拍照示例中 , ...

  3. 【Flutter】开发 Flutter 包和插件 ( 开发 Dart 插件包 | 发布 Dart 插件包 )

    文章目录 前言 一.开发 Dart 包内容 二.配置 pubspec.yaml 三.编写 Dart 包使用说明 四.编写 Dart 包授权许可 五.设置版本变更记录 六.验证 Dart 包正确性 七. ...

  4. 【Flutter】开发 Flutter 包和插件 ( Flutter 包和插件简介 | 创建 Flutter 插件 | 创建 Dart 包 )

    文章目录 一.Flutter 包和插件简介 二.创建 Flutter 插件 1.Android Studio 中可视化创建 2.命令行创建 三.创建 Dart 包 1.Android Studio 中 ...

  5. 【Flutter】自定义 Flutter 组件 ( 创建自定义 StatelessWidget、StatefulWidget 组件 | 调用自定义组件 )

    文章目录 一.Flutter 组件简介 二.Flutter 自定义 StatelessWidget 组件流程 1.导入父类包 2.选择继承的父类 3.设置成员变量及构造函数 4.重写 build 方法 ...

  6. 【Flutter】Flutter 混合开发 ( Flutter 与 Native 通信 | 完整代码示例 )

    文章目录 前言 一.Android 端完整代码示例 二.Flutter 端完整代码示例 三.相关资源 前言 前置博客 : [Flutter]Flutter 混合开发 ( Flutter 与 Nativ ...

  7. 【Flutter】Flutter 混合开发 ( Flutter 与 Native 通信 | Android 端实现 MethodChannel 通信 )

    文章目录 前言 一.Android 端 MethodChannel 构造函数 二.Android 端 setMethodCallHandler 方法 三.Android 端实现 MethodChann ...

  8. 【Flutter】Flutter 混合开发 ( Flutter 与 Native 通信 | Android 端实现 EventChannel 通信 )

    文章目录 前言 一.Android 端 EventChannel 构造函数 二.Android 端 setStreamHandler 方法 三.Android 端实现 EventChannel 通信步 ...

  9. 【Flutter】Flutter 混合开发 ( Flutter 与 Native 通信 | Android 端实现 BasicMessageChannel 通信 )

    文章目录 前言 一.Android 端 BasicMessageChannel 构造函数 二.Android 端 MessageCodec 子类实现 三.Android 端 setMessageHan ...

最新文章

  1. 李开复张亚勤巅峰对话,还有虚拟人自动驾驶论坛,今年MEET智能未来大会,我蚌埠住了...
  2. a,b互换,不使用中间变量
  3. linux ls mv,Linux基本命令总结一(ls,cp,rm,mv,mkdir,rmdir,cd)
  4. EasyUI datagrid : 启用行号、固定列及多级表头后,头部行号位置单元格错位的问题...
  5. 虚拟机7.1.4序列号
  6. [JS-BOM]BOM_Location地址栏对象
  7. spring 多线程 事务的实际应用场景
  8. 关于web项目跨域问题详解
  9. TechOnTheNet的Oracle专题
  10. 两个offer如何做选择?年薪20万vs年薪15万
  11. RN调试利器——React Native Debugger
  12. geany怎么创建文件夹_在visual studio中创建win32应用程序
  13. linux重启ipv6_过渡到 nftables | Linux 中国
  14. c语言万能头文件用不,万能头文件不能用?
  15. linux 执行文件命令
  16. self-supervised text erasing with controllable image synthesis
  17. linux获得命令使用帮助
  18. 基于python的-正则中的函数
  19. 无穷小微积分词汇索引怎么使用?
  20. [java] 设计模式

热门文章

  1. Anaconda 安装教程(Win10环境) Tensorflow安装
  2. HTML+CSS学习笔记(3)- 认识标签(2)
  3. 库会因为权限问题无法打开——selinux开启严格模式
  4. Android自动化测试工具—Monkey简介及入门
  5. 让孩子从小自信的28个方法
  6. win 7 连接打印机
  7. Linux kernel 内核升级与降级实战
  8. 在线 SQL 数据库环境
  9. 计算机常用技巧及快捷键
  10. 实战 | F1060路由模式典型组网配置案例(静态路由)