Android整合React Native啟動白屏問題優化

RmondJone發表於2018-08-09

一、關於預載入方案預研

有一個方案是使用記憶體換取讀取時間的一種折中的方案,網上通篇也說的這個方案。關於這個給大家一個連結,大家可以參考。

React-Native 安卓預載入優化方案

對比IOS端與Android端的首屏時間資料,我們發現安卓端佔有一定的劣勢,我們在啟動React-Native安卓應用時,會發現第一次啟動React-Native安卓頁面會有一個短暫的白屏過程,而且在完全退出後再進入,仍然會有這個白屏,為什麼Android端的白屏時間較IOS較長呢?我們首先分析React-Native頁面載入各個階段的時間響應圖

Android整合React Native啟動白屏問題優化

我們可以看到耗時最長的是JsBundle離線包的載入與解析。使用上面的那種全域性Map存放RootView的方案,只是優化的是從Bundle解析頁面的時間。追蹤React Native原始碼這種方案優化的只是這一部分的時間,本質上並不能解決啟動白屏的現像。

  /**
   * Schedule rendering of the react component rendered by the JS application from the given JS
   * module (@{param moduleName}) using provided {@param reactInstanceManager} to attach to the
   * JS context of that manager. Extra parameter {@param launchOptions} can be used to pass initial
   * properties for the react component.
   */
  public void startReactApplication(
      ReactInstanceManager reactInstanceManager,
      String moduleName,
      @Nullable Bundle initialProperties) {
    Systrace.beginSection(TRACE_TAG_REACT_JAVA_BRIDGE, "startReactApplication");
    try {
      UiThreadUtil.assertOnUiThread();

      // TODO(6788889): Use POJO instead of bundle here, apparently we can't just use WritableMap
      // here as it may be deallocated in native after passing via JNI bridge, but we want to reuse
      // it in the case of re-creating the catalyst instance
      Assertions.assertCondition(
        mReactInstanceManager == null,
        "This root view has already been attached to a catalyst instance manager");

      mReactInstanceManager = reactInstanceManager;
      mJSModuleName = moduleName;
      mAppProperties = initialProperties;

      if (!mReactInstanceManager.hasStartedCreatingInitialContext()) {
        mReactInstanceManager.createReactContextInBackground();
      }

      attachToReactInstanceManager();

    } finally {
      Systrace.endSection(TRACE_TAG_REACT_JAVA_BRIDGE);
    }
  }
複製程式碼

二、徹底解決應用白屏的方案

其實說起來也很簡單,只是我們在使用時不會去注意這麼細節。上面也說了要優化白屏,那就著重2個方面入手一個是JsBundle的載入一個是JsBundle的解析。前一種方案只是優化了JsBundle的解析的時間,那麼載入JsBundle是在哪一個函式里載入的呢?

分析原始碼首先我們來看getReactNativeHost(),這是一個介面函式,我們的實現是傳遞一些初始化的ReactPackage和JSBundle檔名。

     private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
        @Override
        public boolean getUseDeveloperSupport() {
            return BuildConfig.DEBUG;
        }

        @Override
        protected List<ReactPackage> getPackages() {
            return Arrays.<ReactPackage>asList(
                    new MainReactPackage(),
                    new RNFSPackage(),
                    new LottiePackage(),
                    new AutoHeightWebViewPackage(),
                    new MReactPackage(),
                    new LinearGradientPackage(),
                    new CodePush(BuildConfig.CODEPUSH_KEY, SysApplication.this, BuildConfig.DEBUG),
                    new SvgPackage(),
                    new RNViewShotPackage()
            );
        }

        @Override
        protected String getJSBundleFile() {
            return CodePush.getJSBundleFile();
        }
    };

    @Override
    public ReactNativeHost getReactNativeHost() {
        return mReactNativeHost;
    }
複製程式碼

好像這裡getJSBundleFile有點像,但只是返回檔名,並不能執行真正的載入,看來載入JSBundle不在這裡,繼續追蹤getReactInstanceManager()方法。追蹤到createReactInstanceManager()方法裡可以看到ReactNativeHost中宣告的getJSBundleFile()在這裡呼叫。使用ReactInstanceManagerBuilder構造ReactInstanceManager

  protected ReactInstanceManager createReactInstanceManager() {
    ReactMarker.logMarker(ReactMarkerConstants.BUILD_REACT_INSTANCE_MANAGER_START);
    ReactInstanceManagerBuilder builder = ReactInstanceManager.builder()
      .setApplication(mApplication)
      .setJSMainModulePath(getJSMainModuleName())
      .setUseDeveloperSupport(getUseDeveloperSupport())
      .setRedBoxHandler(getRedBoxHandler())
      .setJavaScriptExecutorFactory(getJavaScriptExecutorFactory())
      .setUIImplementationProvider(getUIImplementationProvider())
      .setJSIModulesProvider(getJSIModulesProvider())
      .setInitialLifecycleState(LifecycleState.BEFORE_CREATE);

    for (ReactPackage reactPackage : getPackages()) {
      builder.addPackage(reactPackage);
    }

    String jsBundleFile = getJSBundleFile();
    if (jsBundleFile != null) {
      builder.setJSBundleFile(jsBundleFile);
    } else {
      builder.setBundleAssetName(Assertions.assertNotNull(getBundleAssetName()));
    }
    ReactInstanceManager reactInstanceManager = builder.build();
    ReactMarker.logMarker(ReactMarkerConstants.BUILD_REACT_INSTANCE_MANAGER_END);
    return reactInstanceManager;
  }
複製程式碼

繼續追蹤ReactInstanceManagerBuilder的build()方法

  /**
   * Instantiates a new {@link ReactInstanceManager}.
   * Before calling {@code build}, the following must be called:
   * <ul>
   * <li> {@link #setApplication}
   * <li> {@link #setCurrentActivity} if the activity has already resumed
   * <li> {@link #setDefaultHardwareBackBtnHandler} if the activity has already resumed
   * <li> {@link #setJSBundleFile} or {@link #setJSMainModulePath}
   * </ul>
   */
  public ReactInstanceManager build() {
    Assertions.assertNotNull(
      mApplication,
      "Application property has not been set with this builder");

    Assertions.assertCondition(
      mUseDeveloperSupport || mJSBundleAssetUrl != null || mJSBundleLoader != null,
      "JS Bundle File or Asset URL has to be provided when dev support is disabled");

    Assertions.assertCondition(
      mJSMainModulePath != null || mJSBundleAssetUrl != null || mJSBundleLoader != null,
      "Either MainModulePath or JS Bundle File needs to be provided");

    if (mUIImplementationProvider == null) {
      // create default UIImplementationProvider if the provided one is null.
      mUIImplementationProvider = new UIImplementationProvider();
    }

    // We use the name of the device and the app for debugging & metrics
    String appName = mApplication.getPackageName();
    String deviceName = getFriendlyDeviceName();

    return new ReactInstanceManager(
        mApplication,
        mCurrentActivity,
        mDefaultHardwareBackBtnHandler,
        mJavaScriptExecutorFactory == null
            ? new JSCJavaScriptExecutorFactory(appName, deviceName)
            : mJavaScriptExecutorFactory,
        (mJSBundleLoader == null && mJSBundleAssetUrl != null)
            ? JSBundleLoader.createAssetLoader(
                mApplication, mJSBundleAssetUrl, false /*Asynchronous*/)
            : mJSBundleLoader,
        mJSMainModulePath,
        mPackages,
        mUseDeveloperSupport,
        mBridgeIdleDebugListener,
        Assertions.assertNotNull(mInitialLifecycleState, "Initial lifecycle state was not set"),
        mUIImplementationProvider,
        mNativeModuleCallExceptionHandler,
        mRedBoxHandler,
        mLazyNativeModulesEnabled,
        mLazyViewManagersEnabled,
        mDelayViewManagerClassLoadsEnabled,
        mDevBundleDownloadListener,
        mMinNumShakes,
        mMinTimeLeftInFrameForNonBatchedOperationMs,
      mJSIModulesProvider);
  }
}
複製程式碼

可以發現有這麼一段程式碼

JSBundleLoader.createAssetLoader(
                mApplication, mJSBundleAssetUrl, false /*Asynchronous*/)
複製程式碼

緊接著看JSBundleLoader的原始碼

  /**
   * This loader is recommended one for release version of your app. In that case local JS executor
   * should be used. JS bundle will be read from assets in native code to save on passing large
   * strings from java to native memory.
   */
  public static JSBundleLoader createAssetLoader(
      final Context context,
      final String assetUrl,
      final boolean loadSynchronously) {
    return new JSBundleLoader() {
      @Override
      public String loadScript(CatalystInstanceImpl instance) {
        instance.loadScriptFromAssets(context.getAssets(), assetUrl, loadSynchronously);
        return assetUrl;
      }
    };
  }
複製程式碼

近loadScriptFromAssets()可以發現這裡呼叫了JNI方法從Assets資料夾裡讀取打包完的JsBundle檔案

  /* package */ void loadScriptFromAssets(AssetManager assetManager, String assetURL, boolean loadSynchronously) {
    mSourceURL = assetURL;
    jniLoadScriptFromAssets(assetManager, assetURL, loadSynchronously);
  }
複製程式碼

那麼是哪個函式呼叫了JSBundleLoader的loadScript(CatalystInstanceImpl instance)了呢?好像明明之中我們也知道了答案,除了createReactContextInBackground()這個方法沒有進去看,其他的基本都看了。那麼我們來看看createReactContextInBackground()這個方法的內部實現。

我們一步步追蹤到這個函式,可以看到這裡有一個mBundleLoader物件,通過全域性搜尋可以得知這個是ReactInstanceManager的一個內部變數,而這個變數就是在上面的ReactInstanceManagerBuilder的build()方法裡初始化的。

  @ThreadConfined(UI)
  private void recreateReactContextInBackgroundFromBundleLoader() {
    Log.d(
      ReactConstants.TAG,
      "ReactInstanceManager.recreateReactContextInBackgroundFromBundleLoader()");
    PrinterHolder.getPrinter()
        .logMessage(ReactDebugOverlayTags.RN_CORE, "RNCore: load from BundleLoader");
    recreateReactContextInBackground(mJavaScriptExecutorFactory, mBundleLoader);
  }
複製程式碼

繼續追蹤recreateReactContextInBackground()方法,最後可以看到BundleLoader物件被用於createReactContext()方法中

           try {
                  Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY);
                  final ReactApplicationContext reactApplicationContext =
                      createReactContext(
                          initParams.getJsExecutorFactory().create(),
                          initParams.getJsBundleLoader());

                  mCreateReactContextThread = null;
                  ReactMarker.logMarker(PRE_SETUP_REACT_CONTEXT_START);
                  final Runnable maybeRecreateReactContextRunnable =
                      new Runnable() {
                        @Override
                        public void run() {
                          if (mPendingReactContextInitParams != null) {
                            runCreateReactContextOnNewThread(mPendingReactContextInitParams);
                            mPendingReactContextInitParams = null;
                          }
                        }
                      };
                  Runnable setupReactContextRunnable =
                      new Runnable() {
                        @Override
                        public void run() {
                          try {
                            setupReactContext(reactApplicationContext);
                          } catch (Exception e) {
                            mDevSupportManager.handleException(e);
                          }
                        }
                      };

                  reactApplicationContext.runOnNativeModulesQueueThread(setupReactContextRunnable);
                  UiThreadUtil.runOnUiThread(maybeRecreateReactContextRunnable);
                } catch (Exception e) {
                  mDevSupportManager.handleException(e);
                }
複製程式碼

進一步追蹤createReactContext()最終發現,JSBundleLoader的loadScript(CatalystInstanceImpl instance)是在CatalystInstanceImpl中的runJSBundle()方法中呼叫。

  @Override
  public void runJSBundle() {
    Log.d(ReactConstants.TAG, "CatalystInstanceImpl.runJSBundle()");
    Assertions.assertCondition(!mJSBundleHasLoaded, "JS bundle was already loaded!");
    // incrementPendingJSCalls();
    mJSBundleLoader.loadScript(CatalystInstanceImpl.this);

    synchronized (mJSCallsPendingInitLock) {

      // Loading the bundle is queued on the JS thread, but may not have
      // run yet.  It's safe to set this here, though, since any work it
      // gates will be queued on the JS thread behind the load.
      mAcceptCalls = true;

      for (PendingJSCall function : mJSCallsPendingInit) {
        function.call(this);
      }
      mJSCallsPendingInit.clear();
      mJSBundleHasLoaded = true;
    }

    // This is registered after JS starts since it makes a JS call
    Systrace.registerListener(mTraceListener);
  }
複製程式碼

那麼前面闡述的問題也便知道了答案,JsBundle是在createReactContextInBackground()中載入的

那麼我們優化也是著重這一塊優化,把這個函式放在Loading頁面裡去載入,把原來載入JsBundle的程式碼從Application挪到Loading頁面(啟動頁:應用啟動第一個頁面)。
這樣有2個好處,一個是Application不會因為載入JsBundle耗時,而遲遲Loading頁顯示不出來,如果沒有做過Android冷啟動優化的App可能就是白屏3S以上或者點選應用圖示沒有要過一會才能進Loading頁面,這樣就加快了應用的啟動速度。
另一個好處就是可以在Loading頁面預載入首頁的React Native頁面,加快首頁的載入時間。還有一個就是今天的重點,怎麼去解決首頁白屏問題,那就是在Loading頁也設定一個ReactRootView,並且給這個View設定setEventListener監聽事件,待JsBundle載入完畢之後,就會走進這個監聽方法裡,在這個方法裡跳轉首頁。這樣就不會引起,JsBundle還未載入完成,就跳近了首頁。導致首頁白屏或者黑屏,需要等JsBundle載入完畢之後才能顯示出來。如下面程式碼所示,initReactNative()在onCreate()中呼叫。

    /**
     * 作者:郭翰林
     * 時間:2018/8/9 0009 17:59
     * 註釋:初始化RN,預載入JsBundle
     */
    private void initReactNative() {
        mReactInstanceManager = ((ReactApplication) GlobalServiceManager.getService(IAppService.class).getAppContext())
                .getReactNativeHost()
                .getReactInstanceManager();
        if (!mReactInstanceManager.hasStartedCreatingInitialContext()) {
            mReactInstanceManager.createReactContextInBackground();
            mReactRootView = findViewById(R.id.reactRootView);
            mReactRootView.startReactApplication(
                    mReactInstanceManager,
                    "NetWorkSettingPage",
                    null
            );
            //設定ReactRootView監聽,如果JsBundle載入完成才允許跳轉下個頁面
            mReactRootView.setEventListener((rootView) -> {
                gotoHomeActivity();
            });
        } else {
            gotoHomeActivity();
        }
    }
複製程式碼

相關文章