IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 移动开发 -> Flutter运行过程(一):一文搞懂Widget更新机制 -> 正文阅读

[移动开发]Flutter运行过程(一):一文搞懂Widget更新机制

本系列将从Flutter框架runApp()运行开始,结合框架源码,分析flutter UI渲染、更新机制,布局、绘制过程,以及解析flutter主要的生命周期过程。认真读完本系列,读者一定会对Flutter运行过程了如指掌、胸有成竹。

本系列将有小量源码出没,建议读者打开编译器,配合框架源码食用,效果更佳。

?

开始的开始

本文主要介绍Flutter的更新机制,为了更好地理解Flutter的更新过程,首先得知道操作系统是如何协调图像的绘制和显示的,这里要引入一个概念:VSync(Vertical Synchronization,垂直同步信号)

2_cpu_gpu关系图

在计算机操作系统中,CPU、GPU和显示器以一钟特定方式协作。

如上图,CPU将计算好的显示内容交给GPU,GPU将内容渲染后放入帧缓冲区(Buffer Queue),他们是帧生产者,不断往帧缓冲区填充数据。

而显示器则是帧消费者,不断从缓冲区中提取帧内容,将其显示在屏幕上。

为了更新屏幕的画面,显示器会以固定的频率从缓冲区中取数据。比如一般的手机屏幕刷新率是60Hz,当一帧图像显示完准备显示下一帧时,显示器会发出一个垂直同步信号(VSync),来提示CPU和GPU进行下一帧的渲染工作,60Hz的屏幕一秒就会发出60次这样的信号。

为什么需要VSync?

这里有两个概念:

  1. Refresh Rate:代表了屏幕在一秒内刷新屏幕的次数,这取决于硬件的固定参数,例如60Hz。
  2. Frame Rate:代表了GPU在一秒内绘制操作的帧数,例如30fps,60fps。

如果没有VSync,会出现两种情况:

  1. 屏幕刷新率大于帧(渲染)率:屏幕显示完一帧时,此时GPU还没渲染完下一帧,此时屏幕会依然显示原来那帧,出现卡帧的情况,也即我们常见的卡顿现象。
  2. 帧率大于屏幕刷新率:假如GPU的帧率为120 fps,而屏幕的刷新率只有60Hz。屏幕一次刷新时,GPU会输出两帧的画面,前一帧会被屏幕丢弃。那么对于用户来说,用户也只能从屏幕上感知到60Hz的刷新率,多余的帧,对于画面的提升没有任何帮助,还会浪费GPU的资源。

所以VSync的作用在于协调屏幕与显卡不同的工作速率,显卡在渲染每一帧之前会等待垂直同步信号,只有显示器完成了一次刷新时,发出垂直同步信号,显卡才会渲染下一帧,确保刷新率和帧率保持同步。

关于Vsync的更多知识,参见02-Understanding VSYNC 理解VSYNC

VSync在Flutter中的作用

那么在Flutter中,VSync是何作用呢?

作用在于通知Flutter框架刷新页面中需要刷新的元素。

这里的刷新,意指app显示出来后(第一帧显示完毕后)的更新页面操作,而flutter app页面第一帧的渲染,是不需要等待VSync信号到来的,而是通过手动刷新。这点读者需要提前知道,后文也会详细提到

Flutter通过向手机平台注册VSync信号监听,当信号来临时,通过回调方法,触发widget的build、layout、paint过程,来更新需要刷新的元素。

具体注册VSync监听的代码体现在PlatformDispatcher这个类里面,这个类封装于dart:ui库中的platform_dispatcher.dart文件中。

PlatformDispatcher

这个类负责派发从平台过来的各种从平台配置到屏幕和窗口的创建或销毁的事件,里面定义了许多的native方法,负责flutter与平台底层的交互。

PlatformDispatcher向上层暴露了核心调度程序API、输入事件回调、图形绘制API和其他此类核心服务。

其中有个方法值得我们关注:

/// Requests that, at the next appropriate opportunity, the [onBeginFrame] and
/// [onDrawFrame] callbacks be invoked.
///
void scheduleFrame() native 'PlatformConfiguration_scheduleFrame';

这个方法用于注册VSync信号的监听,当下一个VSync信号来临时,会调用PlatformDispatcher.onBeginFramePlatformDispatcher.onDrawFrame

在onDrawFrame中会一步步执行build、layout、paint等过程。

更多关于PlatformDispatcher的信息,可以参见源码,也可以阅读谷歌官方对外分享的API文档: 请科学上网

Flutter刷新页面的源码执行流程

先看当VSync信号回调后,Flutter做的工作

前面说到信号回调后,会执行PlatformDispatcher的onBeginFrame和onDrawFrame。

那PlatformDispatcher的实例什么时候被创建?其属性onBeginFrame和onDrawFrame是在哪里赋值的?
FrameCallback? get onBeginFrame => _onBeginFrame;
FrameCallback? _onBeginFrame;

VoidCallback? get onDrawFrame => _onDrawFrame;
VoidCallback? _onDrawFrame;

PlatformDispatcher绝大部分时间都是以一个单例存在,会在用到它的第一刻被初始化。

PlatformDispatcher._() {	// 私有构造方法,用于创建单例
  _setNeedsReportTimings = _nativeSetNeedsReportTimings;
}
static PlatformDispatcher get instance => _instance;
static final PlatformDispatcher _instance = PlatformDispatcher._();

?

而在分析onBeginFrame和onDrawFrame的赋值时机前,我们得简单先过一遍app初始化时都做些什么,因为在runApp初始化时有一些必要的初始化是后文需要用到的。

runApp()的方法源码如下:

void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame();
}

首先会调用WidgetsFlutterBindingensureInitialized()方法,看看内部实现

class WidgetsFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding, ServicesBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding {

  /// Returns an instance of the [WidgetsBinding], creating and
  /// initializing it if necessary. If one is created, it will be a
  /// [WidgetsFlutterBinding]. If one was previously initialized, then
  /// it will at least implement [WidgetsBinding].
  ///
  /// You only need to call this method if you need the binding to be
  /// initialized before calling [runApp].
  static WidgetsBinding ensureInitialized() {
    print("WidgetsFlutterBinding ensureInitialized() begin");
    if (WidgetsBinding.instance == null)
      WidgetsFlutterBinding();
    return WidgetsBinding.instance!;
  }
}

第一次初始化需要调用WidgetsFlutterBinding();,在执行自身的构造方法前,会默认调用父类的无参构造方法,该类继承自BindingBase,看看父类的实现:

abstract class BindingBase {
  /// Default abstract constructor for bindings.
  ///
  /// First calls [initInstances] to have bindings initialize their
  /// instance pointers and other state, then calls
  /// [initServiceExtensions] to have bindings initialize their
  /// observatory service extensions, if any.
  BindingBase() {
    developer.Timeline.startSync('Framework initialization');

    print("BindingBase BindingBase() called");
    initInstances();

    initServiceExtensions();

    developer.postEvent('Flutter.FrameworkInitialization', <String, String>{});

    developer.Timeline.finishSync();
  }
  ....
}

跑下去会调用initInstances(),这个方法BindingBase里面有默认实现,在WidgetsBinding、RendererBinding和其他with的众兄弟间也有实现,他们会调用一遍吗?

class WidgetsFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding, ServicesBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding {}

关于dart,我也是这两天才学的。。extends和with配合使用,还有mixin、on这些的特性我现在也还没理清楚,有知道的读者可以评论告诉我,链接或私聊都是欢迎的。

不过我通过在各个方法中打log知道,其他的initInstances()也会调用,并按着WidgetsBinding,RendererBinding,SemanticsBinding…GestureBinding,BindingBase的顺序分别执行一遍,详见下图

3_initInstances()执行流程

?

那么在WidgetsBindinginitInstances() 方法做了什么呢?

// WidgetsBinding
void initInstances() {
  print("WidgetsBinding initInstances() called");
  super.initInstances();
  _instance = this;

  // Initialization of [_buildOwner] has to be done after
  // [super.initInstances] is called, as it requires [ServicesBinding] to
  // properly setup the [defaultBinaryMessenger] instance.
  _buildOwner = BuildOwner();		// 负责管理widgets的对象
  buildOwner!.onBuildScheduled = _handleBuildScheduled;
  window.onLocaleChanged = handleLocaleChanged;
  window.onAccessibilityFeaturesChanged = handleAccessibilityFeaturesChanged;
  SystemChannels.navigation.setMethodCallHandler(_handleNavigationInvocation);
    return true;
  }());
}

Flutter框架源码中有很多assert()代码,aseert一般都用于测试,为了不影响阅读,我会将assert部分进行删减,后文也会这么处理,在这里进行统一的说明。

首先创建BuildOwner对象,然后执行buildOwner!.onBuildScheduled = _handleBuildScheduled;,这里将_handleBuildScheduled赋值给了buildOwnder的onBuildScheduled属性。

BuildOwner对象,它负责跟踪哪些widgets需要重新构建,并处理应用于widgets树的其他任务,其内部维护了一个_dirtyElements列表,用以保存被标“脏”的elements。

每一个element被新建时,其BuildOwner就被确定了。一个页面只有一个buildOwner对象,负责管理该页面所有的element

当调用buildOwner.onBuildScheduled()时,便会走下面的流程。

// WidgetsBinding类
buildOwner!.onBuildScheduled = _handleBuildScheduled;

// WidgetsBinding类
void _handleBuildScheduled() {
  ensureVisualUpdate();
}

// SchedulerBinding类
void ensureVisualUpdate() {
    switch (schedulerPhase) {
      case SchedulerPhase.idle:
      case SchedulerPhase.postFrameCallbacks:
        scheduleFrame();
        return;
      case SchedulerPhase.transientCallbacks:
      case SchedulerPhase.midFrameMicrotasks:
      case SchedulerPhase.persistentCallbacks:
        return;
    }
  }

当schedulerPhase处于idle状态*(空闲,当前没有帧正在被处理)*

或postFrameCallbacks状态*(当负责[当前帧的clean-up和安排下一帧]的callbacks正在被调用时的状态)*时,会调用scheduleFrame()。

这个scheduleFrame()是不是和PlatfromDispatcher中的scheduleFrame()很像?看看实现

// 以下都在SchedulerBinding类
void scheduleFrame() {
  if (_hasScheduledFrame || !framesEnabled)
    return;
  // ①
  ensureFrameCallbacksRegistered();	
  // ②
  window.scheduleFrame(); 						
  _hasScheduledFrame = true;
}

// 在这个方法里完成对onBeginFrame,onDrawFrame的赋值
@protected
void ensureFrameCallbacksRegistered() {
  window.onBeginFrame ??= _handleBeginFrame;
  window.onDrawFrame ??= _handleDrawFrame;
}
// window的类型是一个FlutterView,FlutterView里面有一个PlatformDispatcher属性
ui.SingletonFlutterWindow get window => ui.window;

// 初始化时把PlatformDispatcher.instance传入,完成初始化
ui.window = SingletonFlutterWindow._(0, PlatformDispatcher.instance);

// SingletonFlutterWindow的类结构
class SingletonFlutterWindow extends FlutterWindow {
  ...
  // 实际上是给platformDispatcher.onBeginFrame赋值
  FrameCallback? get onBeginFrame => platformDispatcher.onBeginFrame;
  set onBeginFrame(FrameCallback? callback) {
    platformDispatcher.onBeginFrame = callback;
  }
  
  VoidCallback? get onDrawFrame => platformDispatcher.onDrawFrame;
  set onDrawFrame(VoidCallback? callback) {
    platformDispatcher.onDrawFrame = callback;
  }
  
  // window.scheduleFrame实际上是调用platformDispatcher.scheduleFrame()
  void scheduleFrame() => platformDispatcher.scheduleFrame();
  ...
}

class FlutterWindow extends FlutterView {
  FlutterWindow._(this._windowId, this.platformDispatcher);

  final Object _windowId;

  // PD
  @override
  final PlatformDispatcher platformDispatcher;

  @override
  ViewConfiguration get viewConfiguration {
    return platformDispatcher._viewConfigurations[_windowId]!;
  }
}

从代码可以看出

①:scheduleFrame()这个方法会被调用多次(setState()也会调用),在第一次调用时,ensureFrameCallbacksRegistered()方法会对onBeginFrame和onDrawFrame进行初始化。也即,在第一次调用SchedulerBinding.ScheduleFrame()时会对onBeginFrame和onDrawFrame进行初始化

②:调用window.schedule()实际上是调用performDispatcher.scheduleFrame()去注册一个VSync监听。

?

关于runApp的ensureInitialized()部分,目前就说到这,后面有提到时再根据需要补充

WidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame();

而紧跟ensureInitialized()后面调用的scheduleAttachRootWidget(app)和scheduleWarmUpFrame();涉及到的是app启动后界面第一帧的绘制,这方面后面再谈,这里先跳过。

说到这感觉有些乱了,在说下一部分前,先对以上部分做个总结:

  1. 在runApp()时,会调用ensureInitialized()方法进行必要的初始化,会以WidgetsBinding,RendererBinding,SemanticsBinding…GestureBinding,BindingBase的顺序执行initInstances(),每个类都有各自的职责,目前只看到WidgetsBinding的部分,它主要负责BuildOwner对象的初始化,这个对象内部维护了dirtyElements列表,用以保存被标“脏”的elements
  2. 在BuildOwner对象中,有一个onBuildScheduled属性,它是一个方法回调,在WidgetsBinding.initInstances()内被同步赋值,调用这个方法,内部会对onDrawFrame()和onBeginFrame()进行赋值(如果之前没被初始化的话),同时调用PlatformDispatcher.scheduleFrame()方法,注册VSync信号监听。

?

VSync信号回调时,Flutter具体做了什么操作?

那得看看_handleBeginFrame_handleDrawFrame的具体实现了。

_handleBeginFrame()的内部会调用handleBeginFrame()

// SchedulerBinding类
void handleBeginFrame(Duration? rawTimeStamp) {
  Timeline.startSync('Frame', arguments: timelineArgumentsIndicatingLandmarkEvent);
  _firstRawTimeStampInEpoch ??= rawTimeStamp;
  _currentFrameTimeStamp = _adjustForEpoch(rawTimeStamp ?? _lastRawTimeStamp);
  if (rawTimeStamp != null)
    _lastRawTimeStamp = rawTimeStamp;

  _hasScheduledFrame = false;
  try {
    // TRANSIENT FRAME CALLBACKS
    Timeline.startSync('Animate', arguments: timelineArgumentsIndicatingLandmarkEvent);
    _schedulerPhase = SchedulerPhase.transientCallbacks;
    final Map<int, _FrameCallbackEntry> callbacks = _transientCallbacks;
    _transientCallbacks = <int, _FrameCallbackEntry>{};
    callbacks.forEach((int id, _FrameCallbackEntry callbackEntry) {
      if (!_removedIds.contains(id))
        _invokeFrameCallback(callbackEntry.callback, _currentFrameTimeStamp!, callbackEntry.debugStack);
    });
    _removedIds.clear();
  } finally {
    _schedulerPhase = SchedulerPhase.midFrameMicrotasks;
  }
}

在这里会遍历_transientCallbacks集合,并执行回调方法,在handleBeginFrame()主要进行的是绘制下一帧的准备工作,让framework准备好下一帧的绘制工作,例如重新设置状态、变量等等。

?

_handleDrawFrame()内部会调用handleDrawFrame()

// SchedulerBinding类
void handleDrawFrame() {
  Timeline.finishSync(); // end the "Animate" phase
  try {
    // PERSISTENT FRAME CALLBACKS
    _schedulerPhase = SchedulerPhase.persistentCallbacks;
    for (final FrameCallback callback in _persistentCallbacks)
      _invokeFrameCallback(callback, _currentFrameTimeStamp!);

    // POST-FRAME CALLBACKS
    _schedulerPhase = SchedulerPhase.postFrameCallbacks;
    final List<FrameCallback> localPostFrameCallbacks =
        List<FrameCallback>.from(_postFrameCallbacks);
    _postFrameCallbacks.clear();
    for (final FrameCallback callback in localPostFrameCallbacks)
      _invokeFrameCallback(callback, _currentFrameTimeStamp!);
  } finally {
    _schedulerPhase = SchedulerPhase.idle;
    Timeline.finishSync(); // end the Frame
    _currentFrameTimeStamp = null;
  }
}

主要工作是遍历两个callback集合

1. _persistentCallbacks

persisentcallback用于执行layout、paint、composite过程

这个callbacks集合通过下面方式注册

void addPersistentFrameCallback(FrameCallback callback) {
  _persistentCallbacks.add(callback);
}

而这个方法,会在RendererBinding的initInstances()中调用,也即我们一开始执行初始化的地方。并且在RendererBinding的初始化中实际上还做了许多工作,一起看看吧

// RendererBinding类
void initInstances() {
  print("RendererBinding initInstances() called");
  super.initInstances();
  _instance = this;
  _pipelineOwner = PipelineOwner(
    onNeedVisualUpdate: ensureVisualUpdate,
    onSemanticsOwnerCreated: _handleSemanticsOwnerCreated,
    onSemanticsOwnerDisposed: _handleSemanticsOwnerDisposed,
  );
  window
    ..onMetricsChanged = handleMetricsChanged
    ..onTextScaleFactorChanged = handleTextScaleFactorChanged
    ..onPlatformBrightnessChanged = handlePlatformBrightnessChanged
    ..onSemanticsEnabledChanged = _handleSemanticsEnabledChanged
    ..onSemanticsAction = _handleSemanticsAction;
  initRenderView();
  _handleSemanticsEnabledChanged();
  addPersistentFrameCallback(_handlePersistentFrameCallback);
  initMouseTracker();
  if (kIsWeb) {
    addPostFrameCallback(_handleWebFirstFrame);
  }
}

首先是,初始化_pipelineOwner,这个对象很重要,它是render树的所有者,负责维护布局、复合、绘制和可访问性语义的脏状态,即负责页面绘制的各种过程。

同时会对window窗口进行初始化赋值,并调用initRenderView()

// RendererBinding类
void initRenderView() {
  renderView = RenderView(configuration: createViewConfiguration(), window: window);
  renderView.prepareInitialFrame();
}

RenderView继承自RenderObject,是整个Render Object树的根对象。

该方法只做了两件事,一是初始化renderView,二是调用renderView.prepareInitialFrame(),将这个根RenderObject加入到pipelineOwner的_nodesNeedingLayout_nodesNeedingPaint列表中,这两个列表很重要,后文说build过程会提到,这里就先说到这里。

接着往下看,调用addPersistentFrameCallback(_handlePersistentFrameCallback);

注册persistentFrameCallback,到了我们的重头戏部分,看看_handlePersistentFrameCallback的实现:

void _handlePersistentFrameCallback(Duration timeStamp) {
  drawFrame();
  _scheduleMouseTrackerUpdate();
}

drawFrame(),在RenderBindng中的实现如下:

// RendererBinding
void drawFrame() {
  pipelineOwner.flushLayout();
  pipelineOwner.flushCompositingBits();
  pipelineOwner.flushPaint();
  if (sendFramesToEngine) {
    renderView.compositeFrame(); // this sends the bits to the GPU
    pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
    _firstFrameSent = true;
  }
}

在WidgetsBindng中实现如下:

// WidgetsBindng
@override
void drawFrame() {
  ...
  try {
    if (renderViewElement != null)
      buildOwner!.buildScope(renderViewElement!);
    super.drawFrame();
    buildOwner!.finalizeTree();
  } finally {}
  ...
}

首先会调用buildOwner!.buildScope(renderViewElement!);在这个方法里面,会执行所有被标记为dirty的element的rebuild过程,rebuild内部又会调用widget.build来新建widget,更新element。

接着调用super.drawFrame(),会进入RendererBinding的drawFrame(),即走layout、paint、composite过程。

所以绘制的流程实际上是:build -> layout -> paint -> composite

以上执行完后,如果更新了element树,一些element不再被用到时,会被放入_inactiveElements列表中,标记为不再活动

紧接着调用buildOwner!.finalizeTree();unmount 所有不再活动的elements

至此一次界面刷新完成。

2. _postFrameCallbacks

_persistentCallbacks是负责帧的绘制过程,而_postFrameCallbacks紧跟着_persistentCallbacks调用,此时当前帧还在显示,post-frame callback负责当前帧的清理工作以及下一帧的工作调度。

值得注意的是,portFrameCallbacks注册后只会被按顺序执行一次,且不可unRegister,执行完毕后就会调用clear(),把callback清理掉。

?

以上,就是当VSync信号回调时,Flutter所做的工作,主要是更新"脏"element

,执行其build,layout、paint、composite流程。

调用setState()的实际操作

已经知道flutter是更新界面的原理,那我们开发者如何触发flutter更新界面呢?

按照上文的理解,我们只需要将要更新的element标”脏“,然后注册VSync信号监听,当下一次VSync信号到来时,flutter就自动为我们遍历这些脏的elements去更新了。

我们最常用的setState()方法内部又是如何做的呢,看看源码吧!

有了前文的铺垫,再看setState()的源码相信会容易很多。

setState()源码位于framework.dart文件中

// example
setState(() {
  _a = 3;
});

// framework.dart
void setState(VoidCallback fn) {
  final Object? result = fn() as dynamic;
  _element!.markNeedsBuild();
}

我们一般会在setState调用的同时,修改一些属性,这样在下次绘制时,就会使用我们更新后的属性,例如example的代码。

到了setState()内部,先是执行了fn,然后执行_element!.markNeedsBuild();

_element就是statefullWidget创建的StatefulElement对象,其markNeedsBuild()方法实现在父类Element中,看看源码:

// Element类
void markNeedsBuild() {
  if (_lifecycleState != _ElementLifecycle.active)
    return;
  if (dirty)
    return;
  _dirty = true;
  owner!.scheduleBuildFor(this);
}

首先先把element标”脏“,然后调用owner!.scheduleBuildFor(this);

这个owner是BuildOwner对象,它就是我们一开始通过runApp()初始化时创建的buildOwner对象。前文说过,它内部维护了一个_dirtyElements列表,用以保存被标“脏”的elements。

接下来看看buildOwner的scheduleBuildFor干了些什么

void scheduleBuildFor(Element element) {
  if (element._inDirtyList) {
    _dirtyElementsNeedsResorting = true;
    return;
  }
  if (!_scheduledFlushDirtyElements && onBuildScheduled != null) {
    _scheduledFlushDirtyElements = true;
    onBuildScheduled!();
  }
  _dirtyElements.add(element);
  element._inDirtyList = true;
}

首先,如果该element已经在dirtyList中了,则设置_dirtyElementsNeedsResorting = true后直接返回。

_dirtyElementsNeedsResorting表,是否需要对dirtElements重新排序,因为之前被标脏的element,它在树中的位置(深度)可能已经变了,需要对dirtyElement重新排序。

否则,如果是新的dirty element,且当前这个dirtyElement列表还未注册过VSync信号监听,则还需要执行onBuildScheduled();,这个方法我们在前文初始化时说过,会调用PlatformDispatcher.scheduleFrame()向平台注册VSync监听。

然后将该dirty element加入到dirty element列表内。

至此,setState()的工作就完成了,当下一个VSync信号到来时,flutter就会自动帮我们更新那些”脏“的element了。

?

最后的最后

这篇可谓一篇长文,很感谢读者能读到现在,我相信认真读完的你一定有所收获,最后我将以上更新过程总结成一幅图,送给大家,帮助大家理解。

4_flutter更新流程图

?

兄dei,如果觉得我写的还不错,麻烦帮个忙呗 😃

  1. 给俺点个赞被,激励激励我,同时也能让这篇文章让更多人看见,(#.#)
  2. 不用点收藏,诶别点啊,你怎么点了?这多不好意思!

拜托拜托,谢谢各位同学!

  移动开发 最新文章
Vue3装载axios和element-ui
android adb cmd
【xcode】Xcode常用快捷键与技巧
Android开发中的线程池使用
Java 和 Android 的 Base64
Android 测试文字编码格式
微信小程序支付
安卓权限记录
知乎之自动养号
【Android Jetpack】DataStore
上一篇文章      下一篇文章      查看所有文章
加:2021-07-15 16:18:51  更:2021-07-15 16:21:28 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年5日历 -2024/5/6 8:23:51-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码