header-bg.jpg
Flutter 开发踩坑记录(持续更新)
发表于 2020-02-05 01:47
|
分类于 Dart
|
评论次数 0
|
阅读次数 84

flutter.png

Flutter 太好学了!BUG 真的太少了! issues 只有 5000 多!也就那么亿点!简单得我都枯了!毕竟每次遇到问题,👴🏻 都是直接去找群里的法佬、低调、Alex 等几位大佬(🐶管理,此处小声哔哔)来解决,只要有大佬在,问题也就不大。虽然法佬经常说要学会看源码,但道理大家其实都懂,看源码也就图一乐,真正有 BUG 还是得找法佬。

不多哔哔,单写一篇文章,先记录它一手。本文记录 👴🏻 在 Flutter 开发中遇到的一些 BUG(as design),避免遗忘,如果正在看文章的你也遇到了,那咱们可以握个手。

容器宽高相关问题

Container 设置宽高不生效

一般是由于父级容器的 constraints 属性引起的,在 Flutter 中,子组件的大小会被父组件的 constraints 属性限制,例如

ConstrainedBox(
  constraints: BoxConstraints(
    minWidth: 100.0, // 最小宽度为 100 逻辑像素
    minHeight: 50.0 // 最小高度为 100 逻辑像素
  ),
  child: Container(
    height: 5.0,// 高度为 5 逻辑像素
    child: redBox 
  ),
)

上面的代码中,Container 组件设置高度为 5 像素,是无法生效的,因为父级容器已经设置了最小高度为 50 像素,所以 Container 组件的最终高度将会是 50 像素。

当然,这肯定不是我们想要的效果,我们就想让 Container 组件的最终高度是 5 像素怎么办?其实很简单,可以使用 UnconstraindBox 解除父级容器的 constraints 属性对子组件大小的限制。例如:

ConstrainedBox(
  constraints: BoxConstraints(
    minWidth: 100.0, // 最小宽度为 100 逻辑像素
    minHeight: 50.0 // 最小高度为 100 逻辑像素
  ),
  child: UnconstraintsBox(
    child: Container(
      height: 5.0, 
      child: redBox 
    ),
  ),
)

UnconstrainedBox 不会对子组件产生任何限制,它允许其子组件按照其本身大小绘制。一般情况下,我们会很少直接使用此组件,但在 “去除” 多重限制的时候也许会有帮助。

其实对于宽高不生效,一般的解决方式是在组件外面套一层布局类组件就可以解决问题,例如以下组件:

Row()
Column()
Align()
Center()
Flex()
Wrap()
Flow()
Stack()

SignleChildScrollView 不满一屏高度时无法撑满全屏

其实和上面这个问题是相似的,可以使用布局类组件解决,或者用如下方式:

Container(
  alignment: Alignment.topLeft,
  child: SingleChildScrollView(),
),

如果你看过 Container 的源码你会发现其实设置 alignment 属性,和用 Align 组件是一回事,源码也是使用 Align 组件,这就是个语法糖,仅此而已。

说到语法糖,其实 Center 组件也是 Align 组件的语法糖,当你不给 Align 传递任何参数时,使用 Center() 和使用 Align() 是一模一样的效果,我的习惯是不管什么情况,都是只用Align 组件。

Container 设置 borderRadius 不生效

ClipRRect 与 直接设置 BorderRadius 是有区别的,可以这么认为:ClipRRect 会直接裁剪成圆角形状,而 BorderRadius 的圆角外的弧形范围是透明的,类似 css 中的 display:noneopaticy:0 的区别。

所以当 borderRadius 不生效时,可以尝试使用 ClipRRect 进行暴力裁剪,然鹅, ClipRRect 的开销是比 borderRadius 更大的,所以能用 borderRadius 就用 borderRadius

元素显示层级问题

Flutter 中 widget 布局的层级关系是递进的,例如 child 的层级比父 Widget 层级更高, ColumnRow 等组件的 children 中同级 widget,谁在后面谁的层级就更高,和 Stackchildren 的层级关系相同。

显示隐藏的几种做法

第一种,利用 IndexedStack 组件控制层级,上面也提到过,子组件谁在后面谁的层级就高,Flutter 中虽然没有 z-index 这一说法,但其实原理和 css 的 z-index 是类似的,index 越大,层级越高,当然这里的 IndexedStackindex 属性是用来控制当前显示的某一个 children,只能显示一个。该方法常用于 APP 首页切换底部导航。

第二种,利用 IgnorePointerOpacity 组件组合隐藏 widget,可以使用 AnimationOpacity 组件达到以前 JQuery 中常用的 fadeIn 效果。

第三种,利用 PositionedTransform.translate 移动到屏幕外,需要显示时再移动回来,这种做法非常适合动画切换,例如视频进度条等效果。

第四种,利用 Offstage 组件,前三种都是利用视觉效果将元素隐藏起来,其实在布局上并未发生改变,而此组件就是类似于 css 中的 display:none,直接让元素在布局中隐藏,不会在布局上继续占用空间。

最后一种,在 build 方法中提前判断,不符合条件直接不渲染,或者返回空 box,这就类似于 HTML 中删除 dom 元素,我人没了,还显示个🔨,所以这是最恐怖的。

GestureDetector 设置 onTap 不生效

Listener 默认的 behaviorHitTestBehavior.deferToChild

如果 Listener 的子组件是一个 Container,这个 Container 不设置 decoration 的情况下,即透明背景色、无边框,则点击 Container 时,无法触发 down、up 等事件。

同理,GestureDetector 是对 Listener 的封装,无法触发 onTap 等事件也是必然的,那么解决办法也很简单,有以下两种解决办法:

1. 给 Container 设置 decoration
2. 将 behavior 属性设置为 opaque 或 translucent

调用 setState 或 markNeedsBuild 后报错

第一种报错

setState() or markNeedsBuild() called during build

遇到此提示,一般解决思路都是利用 addPostFrameCallback 来解决,例如:

WidgetsBinding.instance.addPostFrameCallback((_){
    _model.setOpacity(opacity);
});

第二种报错

setState() called after dispose()

一般定时器在 app 返回桌面后仍在调用 setState 或 页面 pop 销毁后异步任务才完成,此时调用了 setState 必然会出现该提示,那么解决办法也很简单,判断生命周期再执行重构逻辑。

if (!mounted) return;
setState(() {
  // do somthing
});

键盘相关问题

键盘弹出后将布局顶起来了,而不是遮住布局

解决办法:在 scafold 里设置 resizeToAvoidBottomInset: false,键盘会遮住布局,而不是顶起布局。

键盘弹起和收回会引起页面重新build

解决方案:可以利用 showBottomSheet 方法,此方法的原理是 push 进一个新的路由,但不会引起重新 build,代码如下:

  get textField => TextField(
    autofocus: true,
    cursorColor: currentTheme.hoverColor,
    cursorWidth: 1.0,
    textInputAction: TextInputAction.done,
    style: TextStyle(
      color: currentTheme.primaryColorLight,
      fontSize: setSp(32),
    ),
    decoration: InputDecoration(
      hintText: '发一句友善的评论来见证当下吧',
      hintStyle: TextStyle(fontSize: setSp(28)),
      contentPadding: EdgeInsets.symmetric(horizontal: setWidth(31)),
      filled: true,
      fillColor: currentTheme.primaryColorDark,
      border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(setWidth(30)),
          borderSide: BorderSide.none
      ),
    ),
    onSubmitted: (value) {},
  );

  Widget buildTextFieldPage(BuildContext context) {
    return SizedBox.expand(
      child: Stack(
        alignment: Alignment.bottomLeft,
        children: <Widget>[
          Positioned.fill(
            child: GestureDetector(
              behavior: HitTestBehavior.opaque,
              onTap: () => Navigator.pop(context),
              child: Container(color: Colors.black.withOpacity(.5)),
            ),
          ),
          buildInput(),
        ],
      ),
    );
  }

  buildInput({hasTextField = true}) {
    Widget child;

    child = hasTextField
        ? Container(
            decoration: BoxDecoration(
              color: currentTheme.backgroundColor,
              borderRadius: BorderRadius.circular(setWidth(31)),
            ),
            child: textField,
          )
        : GestureDetector(
            onTap: () {
              showBottomSheet(
                context: context,
                backgroundColor: Colors.transparent,
                builder: buildTextFieldPage,
              );
            },
            child: Container(
              decoration: BoxDecoration(
                color: currentTheme.backgroundColor,
                borderRadius: BorderRadius.circular(setWidth(31)),
              ),
            ),
          );

    return Container(
      height: setWidth(103),
      padding: EdgeInsets.symmetric(
        vertical: setWidth(20),
        horizontal: setWidth(25),
      ),
      decoration: BoxDecoration(
        border: Border(top: commentDivider),
        color: currentTheme.primaryColor,
      ),
      child: Row(
        children: <Widget>[
          Expanded(child: child),
          Container(
            width: setWidth(66),
            padding: EdgeInsets.only(left: setWidth(25)),
            alignment: Alignment.center,
            child: Icon(
              IcoMoon.send,
              color: currentTheme.hoverColor.withOpacity(.5),
              size: setWidth(42),
            ),
          ),
        ],
      ),
    );
  }

路由 push pop 常见需求

例如浏览记录中有如下 4 个页面,当前页面为 d

a->b->c->d

在当前页面使用 popUtil(context, 'a'),可以直接返回至 a 页面,并销毁 bc 页面。

在当前页面使用 pushNamedAndRemoveUntil(context, 'e', (route) => false),可以进入 e 页面之前,销毁所有历史记录,即 e 页面变成第一页,e 页面里无法继续 pop 返回上一页。

ios 端 build 错误

提示如下:

Automatically assigning platform iOS with version 9.0 on target Runner because no platform was specified. Please specify a platform for this target in your Podfile.

解决办法是:删除 pod 文件中 platform前的 #

MacOS 虚拟机访问 Windows 的服务

例如 Windows 中有域名为 app.lcgod.com 的本地服务,想要在 mac 虚拟机里访问,修改 hosts 即可,例如:

sudo -i
vi /etc/hosts

添加如下记录并保存:

192.168.0.100 app.lcgod.com

pageView、ListView 等滚动组件切换页面返回后的高度位置被改变了

解决办法:给滚动组件加上 key 属性,例如: key: PageStorageKey(1)

如何监听 app 返回桌面事件

我需要当 app 返回桌面时暂停视频的播放,从桌面返回 app 后再继续播放,解决方案如下:

class _DemoState extends State<Demo> with WidgetsBindingObserver {
  
  void didChangeAppLifecycleState(AppLifecycleState state) {
    print('app lifecycle state: $state');
    if (state == AppLifecycleState.inactive) {
      _playerModel.pausePlayer();
    } else if (state == AppLifecycleState.resumed) {
      if (_homeModel.isFindPage) _playerModel.startPlayer();
    }
    super.didChangeAppLifecycleState(state);
  }
}

TextField 设置 border 不生效

TextField 的 border 有如下 3 种,需要针对性地设置:

decoration: InputDecoration(border enabledBorder focusBorder)

ps:设置 maxLength 属性后,decoration 里需要设置 counterText: '',否则默认会附带一个统计字数的样式。

Dio 相关知识点

Dio 的请求头 content-type 的值默认是

application/json; charset=utf-8

如果返回头是

application/json

Dio 会自动解析为 json 数据,而不需要手动地调用 parseJSON() 方法,所以后端尽量使用 application/json 作为返回头。

打包后无法进行网络请求

android/app/src/profile/AndroidManifest.xml 中以及

android/app/src/main/AndroidManifest.xml 两个文件的 manifest 标签内添加如下子标签即可:

<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

对于类中的属性和方法的定义规范的一些建议

全屏相关设置

强制竖屏:

void initState() {
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
    DeviceOrientation.portraitDown
  ]);
  super.initState();
}

强制横屏:

initState() {
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.landscapeLeft,
    DeviceOrientation.landscapeRight
  ]);
  super.initState();
}

Transform 3D 转换

推荐使用 Transform 组件来完成动画效果,例如 Transform.translateTransform.scale 可以完成宽高、位置的变化, Transform.rotate 可以完成旋转角度的变化。

Transform.rotateRotateBox 都可以完成旋转功能,下面讲讲他们的区别:

使用 RotateBox 渲染 widget 是在 layout 阶段,渲染完毕后就会占用实际位置,而 Transform 组件则是在 layout 之后的绘制阶段, Transform 只是一个视觉效果,实际所占空间大小是 transform 变化之前所占用的空间大小,所以重新渲染 Transform.rotate 组件比重新渲染 RotateBox 开销更小。

Flutter 的 Transform 组件的这个特性和 CSS 的 transform 属性非常相似,都可以用来提升动画性能。

不过做视频全屏功能时,可以用 IndexedStack + RotateBox 替代 push 一个横屏的路由的做法,RotateBox 它会使容器填充全屏,而 IndexedStack 可以控制是否显示全屏,这里如果使用 Transform 则无法填充全屏,因为容器的宽高在 layout 时就已经确定了,所以只能使用 RotateBox

视频镜像翻转

我在项目中不仅使用 RotatedBox 完成视频全屏功能,还利用了 Transform 来完成镜像翻转功能,写法如下:

Selector<VideoModel, bool>(
  selector: (context, model) => model.isMirror,
    builder: (context, isMirror, child) => Transform(
      alignment: Alignment.center,
      transform: Matrix4.identity()..setEntry(3, 2, 0.006)..rotateY(isMirror ? math.pi : 0),
      child: child,
    ),
    child: FijkView(
    player: model.player,
    color: Colors.black,
    panelBuilder: (player, context, size, pos) => emptyBox,
  ),
)

原理很简单,FijkView 是 fijkplayer 提供的视频容器,我将视频容器以中心位置为圆心,沿 Y 轴做一个 180 度的旋转,即可满足需求。

setEntry 用于设置透视,否则将无法看到 Y 轴及 X 轴的立体转换效果

rotateY 则与 css 中的 rotateY 是相同含义,即沿 Y 轴旋转。在 css 中可以设置 transform: rotateY(180deg) 来达到相同的效果。

状态栏相关设置

隐藏状态栏:

import 'package:flutter/services.dart';

void toggleFullscreen() {
  _isFullscreen = !_isFullscreen;
  _isFullscreen
      ? SystemChrome.setEnabledSystemUIOverlays([])
      : SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
}

改变状态栏颜色,则需要使用插件:flutter_statusbarcolor,下面是用法示例:

// 改变状态栏背景颜色,默认改变为透明
Future<void> changeStatusColor({Color color: Colors.transparent}) async {
  try {
    await FlutterStatusbarcolor.setStatusBarColor(
      color,
      animate: true,
    );
    FlutterStatusbarcolor.setStatusBarWhiteForeground(true);
    FlutterStatusbarcolor.setNavigationBarWhiteForeground(true);
  } on PlatformException catch (e) {
    debugPrint(e.toString());
  }
}

下面介绍一个用法,我的 home 页使用 indexStack 组件包含了 4 个 tab 页,每次更改 tab 会改变 currentHomeTab 的值,但不会触发重新 build,而由于路由 pushpop 又会触发重新 build,所以如果需要当进入 home 页的 发现 tab 页 时改变为黑色状态栏,则可以用下面这种做法:

// 在发现页的 build 方法里进行判断

Widget build(BuildContext context) {
  if (ModalRoute.of(context).isCurrent && currentHomeTab == '发现') {
    changeStatusColor(color: Colors.black);
  }
}

fijkplayer 秒开、进度跳转等优化

fijkplayer 默认情况下,进度跳转、播放可能会有性能问题,针对这些问题,可以进行以下优化:

_player.setDataSource(_video.src);
await _player.applyOptions(
    FijkOption()
      ..setFormatOption('flush_packets', 1)
      ..setFormatOption('analyzemaxduration', 100)
      ..setFormatOption('analyzeduration', 1)
      ..setCodecOption('skip_loop_filter', 48)
      ..setPlayerOption('start-on-prepared', 1)
      ..setPlayerOption('packet-buffering', 0)
      ..setPlayerOption('framedrop', 1)
      ..setPlayerOption('enable-accurate-seek', 1)
      ..setPlayerOption('find_stream_info', 0)
      ..setPlayerOption('render-wait-start', 1)
);
await _player.prepareAsync();

参考链接:

IjkPlayer 起播速度优化

IjkPlayer 播放器秒开优化以及常用 Option 设置

LayoutBuilder 相关的实践

如何实现微信朋友圈、哔哩哔哩评论的多行文本收起、展开功能

使用下面这个工具类,简单、好用得我都枯了,原理是利用先 LayoutBuilder 判断是否超出指定的行数,如果超出则返回 Column,如果未超出则返回原 widget

import 'package:flutter/material.dart';

class ExpandableText extends StatefulWidget {
  final String text;
  final int maxLines;
  final TextStyle style;
  final bool expand;
  final TextStyle markerStyle;

  const ExpandableText(this.text, {
    Key key,
    this.maxLines,
    this.style,
    this.markerStyle,
    this.expand = false
  }) : super(key: key);

  
  createState() => _ExpandableTextState(text, maxLines, style, expand);

}

class _ExpandableTextState extends State<ExpandableText> {
  final String text;
  final int maxLines;
  final TextStyle style;
  bool expand;

  _ExpandableTextState(this.text, this.maxLines, this.style, this.expand);

  
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (_, size) {
      final tp = TextPainter(
        text: TextSpan(text: text, style: style),
        maxLines: maxLines,
        textDirection: TextDirection.ltr,
      );
      tp.layout(maxWidth: size.maxWidth);

      if (!tp.didExceedMaxLines) return Text(text, style: style);

      return Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          expand
              ? Text(text, style: style)
              : Text(text, maxLines: maxLines, style: style),

          GestureDetector(
            onTap: () {
              setState(() {
                expand = !expand;
              });
            },
            child: Text(
              expand ? '收起' : '展开',
              style: widget.markerStyle,
            ),
          ),
        ],
      );
    });
  }
}

底部弹出动画的两种实现方式

第一种,利用 showModalBottomSheet,例如常见的分享功能,点击分享按钮后,会从页面底部弹出分享组件,相关实现代码如下:

  void showShareBottomSheet() {
    showModalBottomSheet(
      elevation: 0,
      backgroundColor: currentTheme.highlightColor,
      context: context,
      builder: (context) => Container(
        width: Screens.width,
        decoration: BoxDecoration(color: currentTheme.primaryColor),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Container(
              alignment: Alignment.bottomLeft,
              height: setWidth(59),
              padding: EdgeInsets.only(left: setWidth(42)),
              child: Text(
                '分享',
                style: TextStyle(
                  fontSize: setSp(32),
                  color: currentTheme.highlightColor,
                ),
              ),
            ),
            Container(
              height: setWidth(206),
              padding: EdgeInsets.only(top: setWidth(33), left: setWidth(33)),
              alignment: Alignment.topLeft,
              decoration: BoxDecoration(
                border: Border(
                  bottom: BorderSide(
                    width: setWidth(.7),
                    color: currentTheme.dividerColor,
                  ),
                ),
              ),
              child: Row(
                children: <Widget>[
                  shareIconOfQQ,
                  shareIconOfQQZone,
                  shareIconOfWeChat,
                  shareIconOfWeChatMoments,
                  shareIconOfMicroBlog,
                ],
              ),
            ),
            Container(
              height: setWidth(206),
              padding: EdgeInsets.only(top: setWidth(33), left: setWidth(33)),
              alignment: Alignment.topLeft,
              child: Row(
                children: <Widget>[
                  shareIconOfLink,
                ],
              ),
            ),
            GestureDetector(
              onTap: () {
                Navigator.pop(context);
              },
              child: Container(
                width: Screens.width,
                height: setWidth(125),
                alignment: Alignment.center,
                decoration: BoxDecoration(
                  border: Border(
                    top: BorderSide(
                      width: setWidth(10),
                      color: currentTheme.backgroundColor,
                    ),
                  ),
                ),
                child: Text(
                  '取消',
                  style: TextStyle(
                    fontSize: setSp(36),
                    color: currentTheme.highlightColor,
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

使用 translate 实现

这种方法比 showBottomSheet 动画性能更高,但是代码实现更复杂一点,并且需要依赖模型来更新,我比较喜欢这种方式。

get body => Stack(
  children: <Widget>[
    page,
    Positioned(
      left: 0,
      bottom: 0,
      right: 0,
      child: bottomSheetBox,
    ),
    Positioned(
      left: 0,
      top: 0,
      right: 0,
      bottom: shareBottomSheetHeight,
      child: bottomSheetBoxMask,
    ),
  ],
);

发布评论
还没有评论,快来抢沙发吧!