Flutter 开发小结 | Tips

原文转载自 「掘金Android」 ( https://juejin.im/post/5e7c5180e51d455c597fead6 ) By 水月沐风

预计阅读时间 0 分钟(共 0 个字, 0 张图片, 0 个链接)

接触 Flutter 已经有一阵子了,期间记录了很多开发小问题,苦于忙碌没时间整理,最近项目进度步上正轨,借此机会抽出点时间来统一记录这些问题,并分享项目开发中的一点心得以及多平台打包的一些注意事项,希望能对大家有所帮助😁。

UI 组件使用

官方为我们提供了大量原生效果的组件,如以 Android 中常见的 Material Design 系列组件和 iOS 系统中让设计师们“欲罢不能”的 Cupertino 系列组件。从我这一个月左右对于 Flutter UI 组件的使用情况来看,不得不感慨一句:“真香”。由于本人之前是做 Android 开发的,所以对于 Android 方面的一些“诟病”深有体会。例如,设计师经常让我们还原设计稿中的阴影效果,一般需要设置阴影颜色、x/y偏移量和模糊度等,然而 Android 原生并没有提供支持所有这些属性的一款组件,所以只能我们自己通过自定义控件去实现,现在还有多少人依然通过 CardView 来“鱼目混珠”呢?然而,在 Flutter 中就无需担心这种问题,通过类似前端中常用的盒子组件—— Container 就可以轻松实现。

当然,Flutter 虽然很强大,但 UI 组件也不是万能的,跨平台之路注定漫长而布满荆棘,偶尔也会伴随着一些小问题。

TextField

Container

SafeArea

Android中存在状态栏、底部导航栏,而 iOS 中也存在状态栏和"底部导航条",所以如果我们页面中的边界部分需要固定显示一些小组件,那么我们最好能够在最外层嵌套一层 SafeArea 组件,即让UI组件处于“安全区域”,不至于引起适配问题。

Material( 
  color: AppTheme.surfaceColor,
  child: SafeArea(
    child: Container(),
  ),
)
复制代码

列表组件

Flutter中常见的列表组件有 ListView、GridView、PageView 等,一个完整的应用肯定也离不开这些组件。我们在使用时,需要留意以下几点:

自定义弹窗

Flutter 为我们提供了一些内置的定制弹窗,这里不再一一说明了。如何自定义弹窗?其实很简单,只需要明白:弹窗即页面。以下面的效果为例:

自定义弹窗效果图

相信对于大家来说,上面的UI页面实现起来并不困难,那我们离 Dialog 效果仅剩一步之遥了:点击空白区域关闭。其实,在上面的某段代码中我已经贴了关键代码,细心的小伙伴应该也察觉到了,没错,我们可以通过 Stack 组件包裹半透明蒙层(如Container)和分享功能组件,我们只需为半透明蒙层增加点击事件即可:

Stack(
        children: [
          // 通过空白区域的点击事件来关闭弹窗
          GestureDetector(
            onTap: () {
            //关闭弹窗
              Navigator.maybePop(context);
            },
            child: Container(
              color: AppTheme.dialogBackgroundColor,
              width: MediaQuery.of(context).size.width,
              height: MediaQuery.of(context).size.height,
            ),
          ),
          Container(
          child: ...
          )
     )
复制代码

哈哈,是不是有种恍然大悟的感觉,如此一来,弹窗对于我们来说不就是写一个页面那么简单了吗😄。

InkWell

InkWell 在 Android 中比较常见,俗称“水波纹”效果,属于按钮的一种,它支持设置波纹颜色、圆角等属性。我们偶尔可能会遇到水波纹失效的问题,这一般是因为我们在 InkWell 内部的 child 中设置了背景,从而导致水波纹效果被遮盖。如何解决这个问题?其实很简单,只需要在 InkWell 外层套上 Material 并设置 color 即可:

Material(
  color: Colors.white,
  child: InkWell(
    borderRadius: AppTheme.buttonRadius, // 圆角
    splashColor: AppTheme.splashColor,   // 波纹颜色
    highlightColor: Colors.transparent,  // 点击状态
    onTap: () {}, // 点击事件
    child: Container(
      ...
    ),
  ),
)
复制代码

或者,我们也可以借助于之前实现自定义 Dialog 的思路,使用 Stack 包裹需要点击的区域,并将 InkWell 放在上层:

Stack(
            children: [
              Image(),
              Material(
                  color: Colors.transparent,
                  child: InkWell(
                    splashColor: AppTheme.splashColor,
                    onTap: () {},
                  ),
                )
              )
            ],
          )

复制代码

以上仅列举了部分常见UI组件的使用技巧和问题,如有其他问题欢迎留言探讨。

功能需求实现

除了 Flutter 中的一些 UI 组件的的使用以外,应用自然还需要涉及到很多具体的业务功能需求,常见的有第三方登录、分享、地图、Lottie 动画接入、第三方字体下载和加载等等。这个时候就需要我们灵活变通了,在保证项目进度顺利进行的前提下有选择性地去借助一些插件和工具,或者前往 Flutter 的 Github Issue 社区去寻找答案了,这里也选择几个常用需求简单说一下。

当前设备的系统语言

很多时候我们需要根据当前系统使用的语言去动态选择加载的内容,举个例子,我们经常需要根据当前语言去加载中文或者英文版的用户隐私条款,我们可以借助 Localizations 去获取当前使用语言的 languageCode,进而比对和处理:

 /// 判断当前语言类型
 _navigateToUrl(Localizations.localeOf(context).languageCode == 'zh'
                      ? Api.PRIVACY_POLICY_ZH_CN
                      : Api.PRIVACY_POLICY_EN);
复制代码

第三方登录/分享

这部分当初考虑自己写插件来对接原生的分享sdk,但考虑到时间成本就暂时搁置了,找到几个不错的插件来实现了该部分功能:

Lottie动画

相信大家对 Airbnb 公司推出的这个动画工具已经有所耳闻了,Lottie 支持多平台,使用同一个JSON 动画文件,可在不同平台实现相同的动画效果。现在复杂动画很多时候都借助于它,能够有效减少开发成本和保持动画的高还原度。同样,Flutter 中也有一些封装了 Lottie 动画的插件,让我们可以在 Flutter 上也可以感受到它的魅力。

这里,我个人使用的插件是 flutter_lottie 插件,还算稳定,支持动画属性和进度操作,唯一遗憾就是有段时间没更新了😂,后续考虑到 iOS 方面的兼容性可能会自己写一个插件。在 pubspec.yaml 中依赖操作如下:

# Use Lottie animation in Flutter.
  # @link: https://pub.dev/packages/flutter_lottie
  flutter_lottie: 0.2.0
复制代码

具体使用技巧可参考它的example:github.com/CameronStua…

这里附上控制动画进度的部分代码:

int _currentIndex = 0;
  LottieController _lottieController;
  PageController _pageController = PageController();
  // the key frames of animation
  final ANIMATION_PROGRESS = [
    0.0,
    0.2083,
    0.594,
    0.8333,
    1
  ];
  // the duration of each animation sections
  final ANIMATION_TIMES = [
    2300,
    4500,
    3500
  ];
  // animation progress controller
  Animation<double> animation;
  AnimationController _animationController;

  
  @override
  void initState() {
    super.initState();
    _animationController = new AnimationController(
        duration: Duration(milliseconds: ANIMATION_TIMES[_currentIndex]), vsync: this);
    final Animation curve =
        new CurvedAnimation(parent: _animationController, curve: Curves.linear);
    animation = new Tween(begin: 0.0, end: 1.0).animate(curve);
    animation.addListener(() {
      _applyAnimation(animation.value);
    });
  }

// 布局代码
.......
  
  Positioned(
              bottom: 0,
              child: Container(
                width: MediaQuery.of(context).size.width,
                // 此处为了将动画组件居下放置
                height: AutoSize.covert.dpToDp(667),
                child: LottieView.fromFile(
                  filePath: 'assets/anims/user_guide_anim.json',
                  autoPlay: false,
                  loop: true,
                  reverse: true,
                  onViewCreated: (controller) {
                    _lottieController = controller;
                    Future.delayed(Duration(milliseconds: 1), () {
                      _animationController.forward();
                    });
                  },
                ),
              ),
            ),

// description page view
            Container(
              width: MediaQuery.of(context).size.width,
              height: MediaQuery.of(context).size.height,
              margin: EdgeInsets.only(bottom: 60),
              child: PageView(
                physics: BouncingScrollPhysics(),
                controller: _pageController,
                onPageChanged: (index) {
                  setState(() {
                    _currentIndex = index;
                    _animationController.duration = Duration(milliseconds: ANIMATION_TIMES[index]);
                  });
                  Future.delayed(Duration(microseconds: 600), () {
                    _animationController.forward(from: 0);
                  });
                },
                children: _buildPageGroup(),
              ),
            ),

......
  
  void _applyAnimation(double value) {
    var startProgress = ANIMATION_PROGRESS[_currentIndex];
    var endProgress = ANIMATION_PROGRESS[_currentIndex + 1];
    var progress = startProgress + (endProgress - startProgress) * value;
    _lottieController.setAnimationProgress(progress);
  }

复制代码

简单解释一下上述代码逻辑,我们这里主要借助于 Lottie 来实现用户引导页的切换动画,引导页分为三个画面,所以需要我们记录和保存动画的关键帧和每段画面的执行时间。至于动画的控制执行权交由上层的 PageView 来滑动实现,每次滑动通过 AnimationControllersetState((){}) 来控制和刷新每段动画的执行时间和执行刻度。具体demo效果如下所示:

Flutter中的lottie动画效果

外部字体下载和加载

如果接触过文字编辑功能开发的小伙伴应该都知道,我们一般会提供几十种字体供用户使用,当然,我们不可能在项目打包时就放入这么多字体包,这样显而会严重增加安装包大小。我们一般的做法是:当用户第一次点击想使用某个字体时,我们会先将其下载到手机本地存储,然后加载字体,后续当用户再次选择该字体,那么直接从本地加载即可。那么问题来了,Flutter 目前的示例中仅为我们提供了从本地 Asset 目录下加载字体的方式,显然想要实现上述需求,需要我们自己寻求出路。

幸运的是,上帝为我们关上了一扇门,也为我们打开了一扇窗,Flutter 中为我们提供了一个 FontLoader 工具,它有一个 addFont 方法,支持将 ByteData 格式数据转化为字体包并加载到应用字体资源库:

  /// Registers a font asset to be loaded by this font loader.
  ///
  /// The [bytes] argument specifies the actual font asset bytes. Currently,
  /// only TrueType (TTF) fonts are supported.
  void addFont(Future bytes) {
    if (_loaded)
      throw StateError('FontLoader is already loaded');

    _fontFutures.add(bytes.then(
        (ByteData data) => Uint8List.view(data.buffer, data.offsetInBytes, data.lengthInBytes)
    ));
  }
...

 /// Loads this font loader's font [family] and all of its associated assets
  /// into the Flutter engine, making the font available to the current
  /// application.
  ///
  /// This method should only be called once per font loader. Attempts to
  /// load fonts from the same loader more than once will cause a [StateError]
  /// to be thrown.
  ///
  /// The returned future will complete with an error if any of the font asset
  /// futures yield an error.
  Future<void> load() async {
    if (_loaded)
      throw StateError('FontLoader is already loaded');
    _loaded = true;

    final Iterablevoid
>> loadFutures = _fontFutures.map( (Future f) => f.then<void>( (Uint8List list) => loadFont(list, family) ) ); return Future.wait(loadFutures.toList()); } 复制代码

如此一来,那我们解决思路也就“手到擒来”了:只需要将字体下载到本地并以文件形式存储,在使用时将字体文件再转为 ByteData 数据格式供 FontLoader 加载即可。这里附上简化后的部分关键代码:

/// 加载外部的字体
Future loadFontFile(LetterFont font) async {
    // load font file
    var fontLoader = FontLoader(font.fontName);
    fontLoader.addFont(await fetchFont(font));
    await fontLoader.load();
  }
/// 从网络下载字体资源
Future fetchFont(LetterFont font) async {
    final response = await https.get(
        font.fontUrl);

    if (response.statusCode == 200) {
      // 这里也可以做保存到本地的逻辑处理
      return ByteData.view(response.bodyBytes.buffer);
    } else {
      // If that call was not successful, throw an error.
      throw Exception('Failed to load font');
    }
  }
复制代码

打包上架相关

打包方面也有一部分细节需要注意一下,这里谈一下 Android 和 iOS 开发环境配置和打包差异以及列举部分常见问题,其他问题因人而异,也因版本而异,就不单独拿出来讲了。

Android方面

  1. 开发工具

    Android studio3.6稳定版

  2. 代码编译环境

    Kotlin + AndroidX

    目前Flutter创建项目默认勾选两个选项

  3. 版本号配置

    android/app/build.gradle 中配置 flutterVersionCodeflutterVersionName

    注意:如果在 pubspec.yaml 中配置了version,那么 Flutter 具体打包的版本会实际根据 pubspec.yamlversion 来构建。

  4. 网络配置

    目前 Android 官方不建议采用http请求格式,推荐使用 https,所以,如果项目中使用到了http格式请求,那么需要添加网络配置。首先在 android/app/src/main/res 路径下创建名为 xml 的文件夹:然后创建名为 network_security_config 的 xml 文件,接着将如下代码复制进去:

    xml version="1.0" encoding="utf-8"?>
    <network-security-config>
        <base-config cleartextTrafficPermitted="true"/>
    network-security-config>
    复制代码

    然后在 AndroidManifest.xml 文件中设置 networkSecurityConfig 属性即可:

     <application
            android:name="io.flutter.app.FlutterApplication"
            android:label="Timeory"
            android:icon="@mipmap/ic_launcher"
            tools:replace="android:name"
            android:usesCleartextTraffic="true"
            android:networkSecurityConfig="@xml/network_security_config"
            tools:ignore="GoogleAppIndexingWarning">
       ......
    application>
复制代码
  • 权限配置

    一般我们项目中都会用到权限申请,并且很多 flutter 插件中也会要求我们去自己配置权限,我们可能需要在 AndroidManifest.xml 文件中添加如下常用权限(只是样例):

    <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"/>
        <uses-permission android:name="android.permission.READ_PHONE_STATE"/>
        <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
        <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
        <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
        <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    复制代码

    当然这些还是不够的,Android6.0及以上,我们还需要在代码中动态申请权限,Flutter中有很多优秀的权限申请插件,iOS 上面一般没问题,Android由于碎片化比较严重,可能会在不同机型上出现各种奇怪问题,比如,红米部分机型借助于 permission_hanlder 插件申请定位权限可能会失败的问题,这里需要注意一下,留个心眼。

  • Logo 配置

    Logo 需要在 android/app/src/main/res 中添加和配置,一般只需要准备 hdpimdpixhdpixxhdpixxxhdpi格式即可。另外,Android8.0 及以上需要适配圆角logo,否则在部分高版本机型上会显示 Android 默认的机器人logo。

    具体可以参考该文章:blog.csdn.net/guolin_blog…

  • 打包

    一般情况下我们通过 flutter build apk 来打包,生成的安装在 build/app/outputs/apk/release 目录下,这样打出来的包一般比较大,因为它包含了 arm64-v8aarmeabi-v7ax86_64 三种cpu架构的包。大家可以根据需要有选择性的针对特定机型的cpu架构打包,执行如下命令即可:

    flutter build apk --target-platform android-arm,android-arm64,android-x64 --split-per-abi
    复制代码

    执行完毕后就会在 release 目录下生成三种格式的apk包。

    另外,大家可以选择一些apk体积优化的方案,具体可参考:

    my.oschina.net/u/1464083/b…

    www.jianshu.com/p/555c948e5…

  • iOS 方面

    由于本人之前做 Android 开发,没有接触过 iOS,所以打包到 iOS 平台还是遇到不少问题。

    1. 开发工具:

      Xcode11.3.1 稳定版 (打包环境) + Visual Studio Code 1.42.1 (编码环境)

    2. 代码编译环境:Swift + Objective-C (目前创建Flutter项目默认勾选为swift,由于项目启动时Flutter尚未更新该配置,所以项目中部分插件采用的是oc),希望后面逐步替换为主流的swift。

    3. 版本号配置:

      只需要在Xcode中 Runner -> targets -> General -> Identity 配置即可。

    4. 网络配置

      iOS 中,官方同样约束我们使用 https 请求,如果我们需要暂时使用http格式请求来测试,可以做如下配置:

      Runner -> targets -> General -> Info 中添加 App Transport Security Settings 属性,并在此属性标签内添加 Allow Arbitrary Loads 子属性,并将值设置为 YES 即可。

    5. Logo配置

      iOS 中的 logo 配置只需要找到如下入口:

      点击 ➡️ 即可进入 logo 资源目录,默认的为 Flutter 的官方 logo,我们只需要根据具体 logo 尺寸去替换资源即可。

    6. 国际化语言配置

      项目中如果支持国际化语言,Android 中无需额外配置,iOS 中需要在 Info.plist 中添加 Localized resource can be mixed 属性,并设置值为 YES 即可,否则APP运行后可能会出现实际展示的是英文的情况。

    7. 打包相关

      Xcode打包时切记要使用稳定版,不要使用 beta 版本,否则可能会出现下面的问题:

    以上就是本人对近期 Flutter 开发过程的一点简单总结,如果能够帮助到您那将再好不过😄。刚接触 Flutter 不久,相关阐述可能不够严谨或存在理解错误,如果您发现了,还请指出,感谢您的阅读。

    more_vert