美文网首页跨平台开发公交站
Flutter 启动页的前世今生适配历程

Flutter 启动页的前世今生适配历程

作者: 恋猫月亮 | 来源:发表于2021-12-10 11:27 被阅读0次

    APP 启动页在国内是最常见也是必备的场景,其中启动页在 iOS 上算是强制性的要求,其实配置启动页挺简单,因为在 Flutter 里现在只需要:

    • iOS 配置 LaunchScreen.storyboard
    • Android 配置 windowBackground;

    一般只要配置无误并且图片尺寸匹配,基本上就不会有什么问题,那既然这样,还有什么需要适配的呢?

    事实上大部分时候 iOS 是不会有什么问题,因为 LaunchScreen.storyboard 的流程本就是 iOS 官方用来做应用启动的过渡;而对于 Andorid 而言,直到 12 之前 windowBackground 这种其实只能算“民间”野路子,所以对于 Andorid 来说,这其中就涉及到一个点:

    [Flutter's first frame] + [time needed to jump from raster to main thread and get a next Android vsync] = [Android's first frame].

    所以下面主要介绍 Flutter 在 Android 上为了这个启动图做了哪些骚操作~

    一、远古时期

    在已经忘记版本的“远古时期”FlutterActivity 还在 io.flutter.app.FlutterActivity 路径下的时候,那时启动页的逻辑相对简单,主要是通过 App 的 AndroidManifest 文件里是否配置了 SplashScreenUntilFirstFrame 来进行判断。

     <meta-data
        android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
        android:value="true" />
    

    FlutterActivity 内部 FlutterView 被创建的时候,会通过读取 meta-data 来判断是否需要使用 createLaunchView 逻辑

    • 1、获取当前主题的 android.R.attr.windowBackground 这个 Drawable
    • 2、创建一个 LaunchView 并加载这个 Drawable
    • 3、将这个 LaunchView 添加到 ActivityContentView
    • 4、在Flutter onFirstFrame 时将这个 LaunchView 移除;
    image
        private void addLaunchView() {
           if (this.launchView != null) {
               this.activity.addContentView(this.launchView, matchParent);
               this.flutterView.addFirstFrameListener(new FirstFrameListener() {
                   public void onFirstFrame() {
                       FlutterActivityDelegate.this.launchView.animate().alpha(0.0F).setListener(new AnimatorListenerAdapter() {
                           public void onAnimationEnd(Animator animation) {
                               ((ViewGroup)FlutterActivityDelegate.this.launchView.getParent()).removeView(FlutterActivityDelegate.this.launchView);
                               FlutterActivityDelegate.this.launchView = null;
                           }
                       });
                       FlutterActivityDelegate.this.flutterView.removeFirstFrameListener(this);
                   }
               });
               this.activity.setTheme(16973833);
           }
       }
    

    是不是很简单,那就会有人疑问为什么要这样做?我直接配置 Activityandroid:windowBackground 不就完成了吗?

    这就是上面提到的时间差问题,因为启动页到 Flutter 渲染完第一帧画面中间,会出现概率出现黑屏的情况,所以才需要这个行为来实现过渡

    2.5 之前

    经历了“远古时代”之后,FlutterActivity 来到了 io.flutter.embedding.android.FlutterActivity, 在到 2.5 版本发布之前,Flutter 又针对这个启动过程做了不少调整和优化,其中主要就是 SplashScreen

    自从开始进入embedding 阶段后,FlutterActivity 主要用于实现了一个叫 Hostinterface,其中和我们有关系的就是 provideSplashScreen

    默认情况下它会从 AndroidManifest 文件里是否配置了 SplashScreenDrawable 来进行判断

     <meta-data
          android:name="io.flutter.embedding.android.SplashScreenDrawable"
          android:resource="@drawable/launch_background"
          />
    

    默认情况下当 AndroidManifest 文件里配置了 SplashScreenDrawable,那么这个 Drawable 就会在 FlutterActivity 创建 FlutterView 时被构建成 DrawableSplashScreen

    image

    DrawableSplashScreen 其实就是一个实现了 io.flutter.embedding.android.SplashScreen 接口的类,它的作用就是:

    在 Activity 创建 FlutterView 的时候,将 AndroidManifest 里配置的 SplashScreenDrawable 加载成 splashScreenView(ImageView);,并提供 transitionToFlutter 方法用于执行。

    之后 FlutterActivity 内会创建出 FlutterSplashView,它是个 FrameLayout。

    FlutterSplashViewFlutterViewImageView 添加到一起, 然后通过 transitionToFlutter 的方法来执行动画,最后动画结束时通过 onTransitionComplete 移除 splashScreenView

    所以整体逻辑就是:

    • 根据 meta 创建 DrawableSplashScreen
    • FlutterSplashView 先添加了 FlutterView
    • FlutterSplashView 先添加了 splashScreenView 这个 ImageView;
    • 最后在 addOnFirstFrameRenderedListener 回调里执行 transitionToFlutter 去触发 animate ,并且移除 splashScreenView

    当然这里也是分状态:

    • 等引擎加载完成之后再执行 transitionToFlutter
    • 引擎已经加载完成了马上执行 transitionToFlutter
    • 当前的 FlutterView 还没有被添加到引擎,等待添加到引擎之后再 transitionToFlutter;
       public void displayFlutterViewWithSplash(@NonNull FlutterView flutterView, @Nullable SplashScreen splashScreen) {
           if (this.flutterView != null) {
               this.flutterView.removeOnFirstFrameRenderedListener(this.flutterUiDisplayListener);
               this.removeView(this.flutterView);
           }
    
           if (this.splashScreenView != null) {
               this.removeView(this.splashScreenView);
           }
    
           this.flutterView = flutterView;
           this.addView(flutterView);
           this.splashScreen = splashScreen;
           if (splashScreen != null) {
               if (this.isSplashScreenNeededNow()) {
                   Log.v(TAG, "Showing splash screen UI.");
                   this.splashScreenView = splashScreen.createSplashView(this.getContext(), this.splashScreenState);
                   this.addView(this.splashScreenView);
                   flutterView.addOnFirstFrameRenderedListener(this.flutterUiDisplayListener);
               } else if (this.isSplashScreenTransitionNeededNow()) {
                   Log.v(TAG, "Showing an immediate splash transition to Flutter due to previously interrupted transition.");
                   this.splashScreenView = splashScreen.createSplashView(this.getContext(), this.splashScreenState);
                   this.addView(this.splashScreenView);
                   this.transitionToFlutter();
               } else if (!flutterView.isAttachedToFlutterEngine()) {
                   Log.v(TAG, "FlutterView is not yet attached to a FlutterEngine. Showing nothing until a FlutterEngine is attached.");
                   flutterView.addFlutterEngineAttachmentListener(this.flutterEngineAttachmentListener);
               }
           }
    
       }
    
       private boolean isSplashScreenNeededNow() {
           return this.flutterView != null && this.flutterView.isAttachedToFlutterEngine() && !this.flutterView.hasRenderedFirstFrame() && !this.hasSplashCompleted();
       }
    
       private boolean isSplashScreenTransitionNeededNow() {
           return this.flutterView != null && this.flutterView.isAttachedToFlutterEngine() && this.splashScreen != null && this.splashScreen.doesSplashViewRememberItsTransition() && this.wasPreviousSplashTransitionInterrupted();
       }
    
    

    当然这个阶段的 FlutterActivity 也可以通过 override provideSplashScreen 方法来自定义 SplashScreen

    注意这里的 SplashScreen 不等于 Android 12 的 SplashScreen。

    看到没有,做了这么多其实也就是为了弥补启动页和 Flutter 渲染之间,另外还有一个优化,叫 NormalTheme

    当我们设置了一个 ActivitywindowBackground 之后,其实对性能还是多多少少会有影响,所以官方就增加了一个 NormalTheme 的配置,在启动完成之后将主题设置为开发者自己配置的 NormalTheme

    通过该配置 NormalTheme ,在 Activity 启动时,就会首先执行 switchLaunchThemeForNormalTheme(); 方法将主题从 LaunchTheme 切换到 NormalTheme

        <meta-data
            android:name="io.flutter.embedding.android.NormalTheme"
            android:resource="@style/NormalTheme"
            />
           
    

    大概配置完就是如下样子,前面分析那么多其实就是为了告诉你,如果出现问题了,你可以从哪个地方去找到对应的点

    <activity
        android:name=".MyActivity"
        android:theme="@style/LaunchTheme"
        // ...
        >
        <meta-data
            android:name="io.flutter.embedding.android.NormalTheme"
            android:resource="@style/NormalTheme"
            />
        <intent-filter>
            <action android:name="android.intent.action.MAIN"/>
            <category android:name="android.intent.category.LAUNCHER"/>
        </intent-filter>
    </activity>
    

    2.5 之后

    讲了那么多,Flutter 2.5 之后 provideSplashScreenio.flutter.embedding.android.SplashScreenDrawable 就被弃用了,惊不喜惊喜,意不意外,开不开心

    Flutter 官方说: Flutter 现在会自动维持着 Android 启动页面的效显示,直到 Flutter 绘制完第一帧后才消失。

    通过源码你会发现,当你设置了 splashScreen 的时候,会看到一个 log 警告:

        if (splashScreen != null) {
          Log.w(
              TAG,
              "A splash screen was provided to Flutter, but this is deprecated. See"
                  + " flutter.dev/go/android-splash-migration for migration steps.");
          FlutterSplashView flutterSplashView = new FlutterSplashView(host.getContext());
          flutterSplashView.setId(ViewUtils.generateViewId(FLUTTER_SPLASH_VIEW_FALLBACK_ID));
          flutterSplashView.displayFlutterViewWithSplash(flutterView, splashScreen);
    
          return flutterSplashView;
        }
    

    为什么会弃用?
    其实这个提议是在 https://github.com/flutter/flutter/issues/85292 这个 issue 上,然后通过 https://github.com/flutter/engine/pull/27645 这个 pr 完成调整。

    大概意思就是:原本的设计搞复杂了,用 OnPreDrawListener 更精准,而且不需要为了后面 Andorid12 的启动支持做其他兼容,只需要给 FlutterActivity 等类增加接口开关即可

    也就是2.5之后 Flutter 使用 ViewTreeObserver.OnPreDrawListener 来实现延迟直到加载出 Flutter 的第一帧。

    为什么说默认情况?因为这个行为在 FlutterActivity 里,是在 getRenderMode() == RenderMode.surface 才会被调用,而 RenderMode 又和 BackgroundMode 有关心

    默认情况下 BackgroundMode 就是 BackgroundMode.opaque ,所以就是 RenderMode.surface

    所以在 2.5 版本后, FlutterActivity 内部创建完 FlutterView 后就会执行一个 delayFirstAndroidViewDraw 的操作。

    
    private void delayFirstAndroidViewDraw(final FlutterView flutterView) {
        if (this.host.getRenderMode() != RenderMode.surface) {
            throw new IllegalArgumentException("Cannot delay the first Android view draw when the render mode is not set to derMode.surface`.");
        } else {
            if (this.activePreDrawListener != null) {
                flutterView.getViewTreeObserver().removeOnPreDrawListener(this.activePreDrawListener);
            }
    
            this.activePreDrawListener = new OnPreDrawListener() {
                public boolean onPreDraw() {
                    if (FlutterActivityAndFragmentDelegate.this.isFlutterUiDisplayed && terActivityAndFragmentDelegate.this.activePreDrawListener != null) {
                        flutterView.getViewTreeObserver().removeOnPreDrawListener(this);
                        FlutterActivityAndFragmentDelegate.this.activePreDrawListener = null;
                    }
    
                    return FlutterActivityAndFragmentDelegate.this.isFlutterUiDisplayed;
                }
            };
            flutterView.getViewTreeObserver().addOnPreDrawListener(this.activePreDrawListener);
        }
    }
    
    

    这里主要注意一个参数:isFlutterUiDisplayed

    当 Flutter 被完成展示的时候,isFlutterUiDisplayed 就会被设置为 true。

    所以当 Flutter 没有执行完成之前,FlutterViewonPreDraw 就会一直返回 false,这也是 Flutter 2.5 开始之后适配启动页的新调整。

    最后

    看了这么多,大概可以看到其实开源项目的推进并不是一帆风顺的,没有什么是一开始就是最优解,而是经过多方尝试和交流,才有了现在的版本,事实上开源项目里,类似这样的经历数不胜数:

    image

    相关文章

      网友评论

        本文标题:Flutter 启动页的前世今生适配历程

        本文链接:https://www.haomeiwen.com/subject/nadcfrtx.html