扣丁书屋

干货 | 携程火车票Flutter最佳实践

背景

在竞争激烈的移动时代,各大互联网公司都在争相抢夺市场,如何提高研发效率,快速迭代产品成为非常重要的因素。

跨平台方案能够节约一定开发、测试、运维成本。Flutter是由谷歌开源的跨平台框架,可以快速在 iOS 和 Android 上构建高质量的原生用户界面。

一、 为什么选择Flutter

携程在已经引入了 React Native 的情况下,为什么还会选择 Flutter?更多是对性能的考虑。开发效率与性能体验就像天平两端,需要找到一个平衡点。RN 能够满足我们绝大部分的业务,并且热更、版本控制都很灵活。但是在复杂页面上,特别是在长列表的渲染上,还是存在一定的问题,促使我们去尝试一些新的解决方案。Flutter官宣自绘UI引擎,采用原生方式做渲染,媲美原生体验。

Native 、React Native、Flutter 对比如下:

1.1 研发效率

Flutter具有跨平台性,可以在多端上运行。同时Dart语言作为开发语言,本身的优势就在于它既支持JIT,又支持AOT,在 JIT(Just In Time)即时编译功能下,能提供 Hot Reload 功能。在开发过程中,实时地看到界面改动。生产包AOT编译,将代码编译成 ARM 二进制,从而既可以享受运行时又具有原生语言相近的运行效率。

1.2 扩展性好

Flutter提供了多种不同的Channel,用于 Dart 和平台之间相互通信。通过这些桥方法,使Flutter具有很好地与 Native 和 React Native 进行混合编程的能力。赋予 Flutter 一些 Native 的能力,同时也能很好地让我们在现有 Native 项目混合Flutter开发。

二、 Provider对MVVM架构的实践

在Flutter的开发过程中,特别是一些业务复杂的页面,为了代码结构清晰,模块逻辑解耦,我们一般采用的是模块化的编程思想。随之而来的问题就是,组件之间怎么相互通讯,比如变更了登录态,如何通知其他模块刷新?

推荐使用Provider来管理各个组件的状态,我们实践下来 ,主体布局采用MVVM模式是比较方便做模块化编程的。

2.1 为什么需要使用Provider

如果状态是该组件私有的,则应该由组件自己管理;但是如果状态要跨组件共享,则该状态应该由各个组件共同的父元素来管理。对于组件私有的状态很好理解,当需要刷新当前widget的时候,只需要通过setState()的方法来实现组件重绘的效果;对于跨组件共享的状态,可以使用EventBus来实现。

可是当事件多了的时候,难以正确管理,其次订阅者必须要显式注册状态改变回调,也必须在组件销毁的时候手动解绑以避免内存泄漏。而Provider就可以通过自身的原理,简单地去实现状态共享,不需要麻烦的操作。且Provider是官方推荐的状态管理方式,具有良好的生态环境及维护团队。

2.2 Provider的实现原理

  1. InheritedWidget简单介绍

Provider是基于InheritedWidget的再次封装,InheritedWidget提供了一种数据在Widget树中自上而下传递,共享的方式。我们在根Widget继承了InheritedWidget,然后在该组件中存放一个数据data,那么可以在任意子Widget中来获取该组件的数据并使用。当在任一组件中改变了共享数据data,InheritedWidget组件会自上而下通知所有使用过共享数据的组件并刷新组件,同时会回调didChangeDependencies() 方法。

  1. Provider的原理和流程

共享数据的Model变化后,会自动通知ChangeNotifierProvider,ChangeNotifierProvider内部会重新构建InheritedWidget,而依赖该InheritedWidget的子Widget就会更新。

2.3 Provider的使用方式 架构模式图如下:

1)创建业务ViewModel,在ViewModel内部存放需要共享的数据。ViewModel 继承Flutter SDK中提供的ChangeNotifier类,它继承Listenable,也实现了一个Flutter风格的订阅者模式,其内部实现了addListener(),removeListener()等方法,实现对订阅者的处理。同时最好复写dispose()和notifyListeners()方法,防止用户在调用数据时销毁界面,而等到数据获取到以后通知界面刷新导致Crash。

2)注册状态管理类,使用ChangeNotifierProvider或者MutiProvider将需要共享数据的Widget包起来,单个NotifierProvider时使用ChangeNotifierProvider,多个NotifierProvider时使用MutiProvider包装,如下:

///多个NotifierProvider的时候
return MultiProvider(providers: [
    ChangeNotifierProvider(create: (context) => dataViewModel(mCommonAdvancedFilterRoot,query)),
    ChangeNotifierProvider(create: (context) => UserPreferentialViewModel(query)),
    ChangeNotifierProvider(create: (context) => UserPromotionViewModel())
///需要调用共享数据的子Widget
], child: ListResearchPageful(query));

3)在被包起来的Widget中的任一子组件中获取共享数据ViewModel,可以在StatefulWidget中的builder()方法中获取,也可以使用Builder组件进行获取,如下:

///在StatefulWidget中的build()方法中获取ViewModel
class ListResearchPageState extends TripState<ListResearchPageful> {
@override
  Widget build(BuildContext context) {
///使用Provider包装以后,可以在widget的任一一个子widget获取共享数据并操作数据,在这里就是可以在HotelListView方法下的唯一位置获取ViewModel
    var listViewModel = Provider.of<ListDataViewModel>(context);
    var userPromotionViewModel = Provider.of<UserPromotionViewModel>(context);
    return MediaQuery(
        child: QueryListPage(widget.query, 
    ListDataViewModel, userPromotionViewModel));
  }
}
///借用Builder组件进行获取ViewModel
@override
Widget build(BuildContext context) {
///使用Provider包装以后,可以在widget的任一一个子widget获取共享数据并操作数据,在这里就是可以在ListView方法下的唯一位置获取ListDataViewModel
  var userPromotionViewModel = Provider.of<UserPromotionViewModel>(context);
  return MediaQuery(
      child: Builder(builder: (context) {
var listDataViewModel = Provider.of<ListDataViewModel>(context);
        return queryListPage(
widget.query, listDataViewModel, userPromotionViewModel);
      },));
}

4)获取到ViewModel后,可以在子组件中直接使用viewmodel中的共享数据,如下:

//领券监听
///此处可以直接使用viewModel调用viewmodel中的方法
Event.addEventListener( 
"UPDATE_QUERY_RESULT_LIST",(eventName, eventData) {
  if (isOnPause) {
    listViewModel.isNeedRefresh = true;
    listViewModel.refreshListData(listViewModel.query);
  } else {
    listViewModel.refreshListData(listViewModel.query);
  }
});

2.4 Provider的优势

1)我们的业务代码更专注数据,只要更新Model,UI就会自动更新,不用在状态改变后再去手动调用setState()来显示更新页面。

2)数据改变的消息传递被屏蔽时,我们无需手动去处理状态改变事件的发布和订阅,provider自行处理。

3)在大型复杂应用中,尤其是需要全局共享的状态非常多时,使用Provider将会大大简化代码逻辑,降低出错的概率,提高开发效率。

三、Flutter 性能调优

一个新技术改造完成,我们最关注的当然是性能体验有没有达到预期。那Flutter页面性能评判标准是什么,如何去度量,有没有可视化工具,帮我们去做一些性能调优。

3.1 Flutter渲染原理简介

在做性能优化之前,先让我们了解一下渲染的原理。Flutter的一切皆为Widget。为了性能又区分了 [StatefulWidget]。StatefulWidget 能通过[setState()]来实现刷新。这样的设计方便我们去控制局部刷新,从而提高性能。

Flutter 中的控件会历 Widget -> Element -> RenderObject -> Layer 这样的变化过程,而其中 Layer 的组成由 RenderObject 中的 isRepaintBoundary 标志位决定。

当调用 setState() 时,RenderObject 就会往上的父节点去查找,根据 isRepaintBoundary是否为 true,会决定是否从这里开始往下去触发重绘,来确定要更新哪些区域。

3.2 构建运行Profile模式

Flutter 支持三种模式编译 app,Debug模式、Release模式和Profile模式。Debug 模式 采用JIT编译,支持HotReload,所以在Debug模式下会放大性能问题。性能分析需要确保使用真机并在profile模式下运行,这样拿到的数据是最接近真实性能的。

1)Debug 模式对应 Dart 的 JIT 模式,可以在真机和模拟器上运行。该模式会打开所有的断言,以及所有的调试信息、服务扩展和调试辅助。此外,该模式支持有状态的 Hot reload。

2)Release 模式对应 Dart 的 AOT 模式,只能在真机上运行,不能在模拟器上运行,其编译目标为最终的线上发布。该模式会关闭所有的断言,以及尽可能多的调试信息、服务扩展和调试辅助。此外,该模式优化了应用快速启动、代码快速执行,以及二级制包大小。

3)Profile 模式,基本与 Release 模式一致,只是多了对 Profile 模式的服务扩展的支持,包括支持跟踪,以及一些为了最低限度支持所需要的依赖。该模式用于分析真实设备实际运行性能。

  • 纯 Flutter 项目构建 Profile 模式

flutter run —profile 命令是使用 Profile 模式来编译的。IDE 也是支持这个模式的,例如 Android Studio 提供了 Run > Profile… 菜单选项。

  • Flutter 与 Native 混合项目构建 Profile 模式

a. 打包Flutter工程Profile产物

// 进入flutter项目,执行build-release,并指定输出目录 tripflutter
build-release -o /projects/ctrip_flutter/release -i info

b. 配置Native项目 打包好flutter产物之后,需要导入到native项目并打包。修改Native项目根目录的gradle.properties文件。

### 开启Profile模式 
TRIP_FLUTTER_PROFILE=true 
### 设置profile模式下js使用的产物目录(过程1构建的 ./profile 目录)
TRIP_FLUTTER_LOCAL_OUTPUTS_PATH=/projects/ctrip_flutter/profile

c. 构建Native工程

直接通过IDE运行到手机上。

3.3 性能分析工具及方法

1)performance overlay

平时常用的性能分析工具有performance overlay,通过它可以直观看到当前帧的耗时。在Profile模式下,通过Android Studio 看页面的FPS,注意需要在HotReload 连接的情况下查看。

选中 View > Tool Windows > Flutter Performance。

点击上面图中的箭头所指的按钮,就会在手机或模拟器中打开(如下图所示)。FPS是一个动态过程,页面滑动这个值是一直变化的,最右边的是当前帧。出现红色则表示耗时超过16.6ms,也就是发生丢帧现象,也是我们常说的页面闪动问题。performance overlay的主要功能如下:

  • 获取FPS数值来衡量页面性能,方便对比Flutter、Native页面帧率;
  • 直观统计页面在各个机型上面的表现;
  • 定位页面的具体哪个模块有问题;

2)Dart DevTool

另一个工具是Dart DevTool ,在Android studio右侧,还可以从Flutter inspector里面的more action,以及Flutter Performance底部的入口进入。

目前DevTools支持的功能有如下一些:

  • 检查和分析应用程序的UI布局和状态。
  • 诊断应用的UI 性能问题。
  • 检测和分析应用程序的CPU使用情况。
  • 分析应用程序的网络使用情况。
  • Flutter或Dart应用程序的源代码级调试。
  • 调试Flutter或Dart应用程序的内存使用情况和分析内存问题。
  • 查看运行的Flutter或Dart应用程序的一般日志和诊断信息。

3.4 实战性能技巧

1)懒加载ListView

推荐使用ListView.builder()构建List,这样当Item滚入屏幕时才创建Item,而不是ListView-children,这样会立刻创建所有的Item。

///Bad code 不推荐使用children 构建List
ListView(children: getItems(mList))
List<Widget> getItems(List<FilterNode> mList){
  List<Widget> items=new List<Widget>();
  if(null!=mList){
for(Node node in mList){
items.add(Text("不推荐写法"));
}
  }
  return items;
}

///推荐写法
ListView.builder(
// physics: NeverScrollableScrollPhysics(),
//shrinkWrap: true,
itemCount:mList.length,
itemBuilder: (BuildContext context, int index) {
return Text("推荐使用ListView.builder()");
})
)

注意,无论是ListView还是GridView,只要是设置了shrinkWrap: true属性,都没有了懒加载的效果了。

2)控制刷新范围与次数

  • 尽量避免在滑动监听中触发setStat()刷新视图。

如上图所示,需要滑动的过程中,显示、隐藏标题栏,并且是一个渐变的过程,遇到这种情况,一定要尽量的控制刷新的范围和频次。控制在只在头图可见的情况下面触发setStat(),避免不必要的页面滑动触发刷新。

scrollController.addListener(() {
if (scrollController.offset > scrollHeight && titleAlpha != 255) {
    setState(() {
titleAlpha = 255;
          });
  }

if (scrollController.offset <= 0 && titleAlpha != 0) {
    setState(() {
titleAlpha = 0;
    });
  }

if (scrollController.offset > 0 && scrollController.offset < scrollHeight) {
    setState(() {
titleAlpha = scrollController.offset * 255 ~/ scrollHeight;
         });
  }
});
  • 尽量将setStat()放在放置于视图树的低层级,好处是build时影响范围极小,简称局部刷新。

如上图所示在列表中 Item 中存在大量的倒计时。一定要控制刷新倒计时只影响控件本身,并且只有可视的区域视图是在刷新的,不可见的情况下及时销毁计时器。一直刷整个列表,性能开销是恐怖的。

Widget build(BuildContext context) {
return Text(timeRemaining,
      style: TextStyle(
          color: HotelColors.hotel_list_reduction_sale_color,
          fontSize: 10,
          fontWeight: FontUtil.mediumWeight));
}

3)避免组件重复创建

能复用的组件尽量复用,特别是在组件化编程,页面级的情况下面,每次刷新页面把所有的子组件都重新渲染一遍,性能开销也是很大的。尽量复用,避免不必要的视图创建。

///存放界面所有的widgets,用以缓存
List<Widget> widgets = new List<Widget>();
///因为头部布局是静态的不刷新,使用变量控制是否复用以前的widgets
var refreshPage = true;
///获取界面布局所有的widgets
List<Widget> getPageWidgets(ScriptDataEntity data) {
if(widgets.isNotEmpty && !refreshPage) {
   return widgets;
  }
}

四、Flutter 布局技巧

4.1 Flutter 不可见组件预加载

Flutter 一些组件基本都是有懒加载的,不可见的组件是没有渲染视图的,这样滑动过去,有用到网络图片的地方,经常会先白一下。针对这种情况我们对将要加载的图片进行预加载处理,比如列表页在分页请求数据回来的时候做图片预加载。还有,下一个页面的图片,需要一进去就有图片直接显示,就可以在当前页面做图片预加载。 预加载

未预加载

代码如下所示:

///对每一页加载的数据进行做图片预加载
(hotelListViewModel.currentPageHotels ?? []).forEach((element) {
var logo = element?.logo ?? "";
  if (StringUtil.isNotEmpty(logo)) {
    precacheImage(NetworkImage(logo), context);
  }
});

当数据出来后使用PreChcheImage()预加载处理图片链接,以保证当用户滑动图片以后不会看到图片加载白屏这种问题。

4.2 Flutter 数据预加载

为了缩短用户的加载等待时长,我们经常需要一些预加载方法。比如在前一个页面预加载下一个页面的数据,或者在长列表的分页请求时候,可以做分页预加载。比如当你滑动到第五个可见的时候,就提前把下一页的数据加载好。

列表页通过桥方法获取上一个页面预加载的数据,这样就能有一个直出体验,这里要考虑数据已经加载好、加载中、加载失败的情况。同时还要考虑,缓存数据的时效性,什么情况下需要删除缓存。

///请求列表数据数据
void loadListData(HotelQuery query) {
///在首页提前获取列表页的数据并缓存到本地,当用户进入列表页时可以直接展示数据
  if (resultModel != null) {
    ///判断是否需要再次请求数据
    _dealWithResult(resultModel);
    return;
  } else if (isPreloading) {
    ///通过桥方法获取首页已经缓存的数据        HotelBridge.getListCache<Map>({'queryModel':query.toJson()})
        .then((resp) {
    final newResultModel = 
          QueryResultModel.fromJson(resp);
      ///有缓存数据直接处理使用
      _dealWithResult(newResultModel);
    }).catchError((error) {
      ///没有数据采取请求列表页的数据
      getHotelList();
    });
  }
}

4.3 布局自适应高度

如果需要根据内容填充的高度来自适应左边图片的高度,目前Flutter并不支持该功能,我们可以借助IntrinsicHeight组件来完美地解决该问题。InstrinsicHeight可以让同一行的子widget都是相同的高度。

  • 可以将需要自适应高度的Widget使用ConstrainedBox进行包裹,并设置最低高度;
  • 将图片作为Container的背景图片,使用DecorationImage进行修饰当前的Container;
  • 将图片的填充方式设置为BoxFit.Cover或者fillHeight即可;

五、Flutter 中常见问题分析及解决方案

5.1 设置State引起的问题

1)错误展示信息:

NoSuchMethodError: The method markNeedsBuild was called on null。

2)错误分析

这个错误一般情况下出现在异步任务,比如一些界面请求网络数据,异步获取本地数据等,需要根据数据的状态来改变刷新Widget State。异步任务结束在页面被销毁之后,没有检查State是否还是mounted状态,继续setState()就会出现这个错误。错误代码如下所示:

///从服务器端获取当前活动终止时间,当服务器返回以后,会通知刷新这里
///如果用户在数据返回之前销毁该界面,等数据回来以后刷新界面就会报错
final endTime = roomDetailItemEntity?.tonightEndTime ?? '';
int endTimeOfNum = 0;
if (endTime.isNotEmpty) {
    try {
        endTimeOfNum = int.parse(endTime) ?? 0;
        if(endTimeOfNum - Util.currentTimeMillis() > 0) {
            this.setState(() {
                _showCountDown = true;
            });
        }
  } catch (e) {}
}

3)处理办法

在调用setState()方法之前检查是否mounted,mounted是一个标示当前Widget树是否已经被渲染的状态值。所以mounted检查很重要,只要涉及到异步还有各种回调的时候,都不能忘记检查该值。如下:

final endTime = roomDetailItemEntity?.tonightEndTime ?? '';
int endTimeOfNum = 0;
if (endTime.isNotEmpty) {
    try {
        endTimeOfNum = int.parse(endTime) ?? 0;
        if(endTimeOfNum - Util.currentTimeMillis() > 0) {
            if(mounted) {
                this.setState(() {
                _showCountDown = true;
                });}}} catch (e) {}
}

5.2 使用MediaQuery.of()动态获取屏幕属性的问题

1)错误展示信息

BoxConstraints has a negative minimum width;

2)错误分析

这种情况一般出现在需要获取屏幕宽度,根据屏幕宽度减去另外一个组件的宽度,用来设置另外一个组件的宽度导致,在一些计算速度比较低的手机,可能获取到的屏幕宽度为0,这样就会导致你的组件的宽度为负数,报出错误异常。如下所示:

Widget hotelListDesContent(BuildContext context) {
return Container(
///此处想实现左边是图片,右边是相关信息的布局,如果MediaQuery.on(context).size.width获取为0时,就会报出异常
    width: MediaQuery.of(context).size.width - Dimens.image_width80,
  ///右边内容
  child: Stack(children: [
    Container(child: Column(
                  mainAxisSize: MainAxisSize.min,
                  mainAxisAlignment: MainAxisAlignment.start,
                  children: <Widget>[
                    hotelListDesName(),
                    englishName(),
                    hotelListRemarkContent(),],),),
          ///左边图片
          Positioned(child: fullRoomItem()),
        ],
));
}

3)处理方式

尽量使用Expand,Flexible,Flex,Wrap,Stack等组件配合Column,Row进行动态布局设置组件的宽高等。如下所示:

Widget hotelListDesContent(BuildContext context) {
return Expanded(
      flex: 1,child: Stack(
      children: [Container(
           child: Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.start,
              children: <Widget>[
                hotelListDesName(),
                englishName(),
                hotelListRemarkContent(),],),),
          Positioned(child: fullRoomItem()),
        ],
      ));
}

5.3 使用Provider时,未判断界面状态通知界面刷新的问题

1)错误信息展示

Null check operator used on a null value;

2)错误分析

一般情况下出现这种问题是由于界面销毁后,继续调用notifyListeners()方法通知界面刷新引起的bug。当用户打开一个界面,我们发送了API请求,此时用户销毁了界面,我们并未监听,等到数据返回以后,强行通知界面刷新,导致Crash。如下所示:

HotelServices.getTyHotelRoomPrice(params, ApiCallBack(onSuccess: (Object obj) {
this.roomPriceEntity = HotelRoomPriceEntity.fromJson(obj);
  this.resultCode = 1;
  ///如果在数据返回是,用户已经关闭当前界面,此处通知刷新界面会导致crash
  notifyListeners();
}, onError: (int code, String message) {}
  notifyListeners()
}));

3)处理方式

正常情况下,我们会写一个基类继承ChangeNotifier,在内部重新复写dispose()方法,同时重新封装方法通知刷新界面,在每次需要通知刷新界面的时候判断当前界面是否已经被销毁。如下所示:

import 'package:flutter/cupertino.dart';
/// ViewModel基类
class HotelViewModel extends ChangeNotifier{
  bool _disposed = false;
  @override
  void dispose() {
    _disposed = true;
    super.dispose();
  }
  void hotelNotifyListeners() {
    if(!_disposed){
      notifyListeners();
    }
  }
}

5.4 使用Text.rich时导致的问题

1)错误信息展示:UnimplementedError

2)错误分析

出现这个问题的原因在于使用Text.rich来展示多个Span组件时,如果设置了最大行数,当组件超过最大行数,有别的组件未成功展示时,再次点击当前widget,使它接受时间,就会导致crash,用户的感知为操作无响应,其实已经crash。如下所示:

///母房型名称, 当前我们Text最大显示两行,当大于两行是,出现...,可是此时第二个组件无处显示,当用户点击就会crash
Row(children: <Widget>[
Expanded(child: Text.rich(TextSpan(
    children: [TextSpan(
    text: itemRoomEntity.baseName ??""),
      WidgetSpan(
        child: Container(
          padding: EdgeInsets.only(bottom: Dimens.gap_dp3),
          child: Icon(HotelIcons.show_more),
        ),
      ),
    ]), maxLines: 2, overflow: TextOverflow.ellipsis),
  ),
], crossAxisAlignment: CrossAxisAlignment.center,),

3)解决办法

使用Flexible代替Expanded,直接使用Text即可,区别在于Flexible不会自动填充整个剩余宽度,如下所示:

///母房型名称
Row( mainAxisAlignment: MainAxisAlignment.start,
      children: <Widget>[
Flexible(child: Text((childCount > 1)?itemRoomEntity.baseName ?? "":"",
        maxLines: 2,
        overflow: TextOverflow.ellipsis,),),
Container(child: Icon(childCount ==1?HotelIcons.show_more:null),
      margin: EdgeInsets.only(top: Dimens.gap_dp2),),
  ], crossAxisAlignment: CrossAxisAlignment.center,)

六、总结与展望

总结一下,本文我们介绍了选择Flutter的初衷,Provider 状态管理的实际使用,建议Flutter主体的构架采用MVVM模式,还介绍了一些Flutter性能检测、量化工具和一些性能优化点供大家参考。收集了Flutter开发过程中常见并且大量发生的问题,并提供了相应的解决方案。

在复杂业务和长列表上面体验,确实 Flutter 优于 React Native。但是React Native 也有它的优势,比如灵活的版本迭代。没有最好的跨平台方案,只有最合适业务的。目前来说,Flutter还处于早期阶段,随着Flutter2.0的重大升级,其跨平台能力、性能、生态系统将会蓬勃发展,还是很值得尝试的。后续我们也将有更多的业务接入Flutter。


https://mp.weixin.qq.com/s/VP6WEQkEel3W4tdo3ThYDw

最多阅读

如何有效定位Flutter内存问题? 9月以前  |  10184次阅读
Flutter的手势GestureDetector分析详解 2年以前  |  5109次阅读
Flutter插件详解及其发布插件 2年以前  |  4192次阅读
在Flutter中添加资源和图片 2年以前  |  3807次阅读
发布Flutter开发的iOS程序 2年以前  |  3034次阅读
Flutter 状态管理指南之 Provider 2年以前  |  2781次阅读
在Flutter中发起HTTP网络请求 2年以前  |  2685次阅读
Flutter for Web详细介绍 2年以前  |  2639次阅读
使用Inspector检查用户界面 2年以前  |  2606次阅读
Flutter Widget框架概述 2年以前  |  2357次阅读
Flutter框架概览 2年以前  |  2200次阅读
JSON和序列化 2年以前  |  2198次阅读
为Flutter应用程序添加交互 2年以前  |  2175次阅读
Flutter路由详解 2年以前  |  2169次阅读
处理文本输入 2年以前  |  2042次阅读
使用自定义字体 2年以前  |  2033次阅读
编写国际化Flutter App 2年以前  |  1992次阅读
推荐5个Flutter重磅开源项目! 1年以前  |  1987次阅读

手机扫码阅读