如何在 React Native 中设置闪屏
闪屏是 App 启动时的一种特殊的界面,它可以在 App 启动时显示一个加载动画,并且可以让用户知道 App 正在加载。
闪屏包含两个阶段,分别是启动阶段和 加载阶段 。启动阶段是指 App 开始启动到启动完成,但是数据还没准备好,主界面还没完全显示的阶段。加载阶段是指 App 准备数据,到主界面渲染完成的阶段。
这两个阶段需要分别设置两个不同的闪屏,但在用户看来,闪屏是一样的。
任何一个阶段没处理好,都有可能出现白屏的情况。先不说 JavaScript Bundle 的加载需要时间,光是启动阶段,JavaScript 就无法参与。因此,闪屏必须放到原生侧来实现。
作为一名熟悉原生开发的工程师,实现闪屏是必备技能,是比较简单的。
本文详细介绍了如何基于原生实现闪屏,给那些不太熟悉原生开发的小伙伴提供一个参考。
制作闪屏 logo
根据 Android 官方闪屏设计指南open in new window,制作闪屏。
这个示例选择的是外圈 288 内圈 192 的尺寸。
分别导出 2 倍图和 3 倍图。
实现 Android 闪屏
我们分别来实现启动阶段的闪屏和加载阶段的闪屏。
Android 启动阶段的闪屏
将闪屏要用到的 2 倍图和 3 倍图分别放到 res/mipmap-xhdpi 和 res/mipmap-xxhdpi 目录下,如图:
在 android/app/src/main/res 目录下新建一个名为 drawable 的文件夹
在 drawable 文件夹下,新建一个名为 splash.xml 的文件
文件内容如下:
<?xml version="1.0" encoding="utf-8"?> <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <item> <shape > <solid android:color="#640263" /> </shape> </item> <item> <bitmap android:antialias="true" android:dither="true" android:filter="true" android:gravity="center" android:src="@mipmap/logo" /> </item> </layer-list>
注意第 5 行是闪屏的背景颜色,第 15 行是闪屏的 logo。
修改 android/app/src/main/res/values/styles.xml 文件,在 styles.xml 文件中修改 AppTheme
以及新建一个名为 SplashTheme
的样式,代码如下:
<resources xmlns:tools="http://schemas.android.com/tools"> <!-- Base application theme. --> <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar"> <!-- Customize your theme here. --> <item name="android:textColor">#222222</item> <item name="android:editTextBackground">@android:color/transparent</item> <item name="android:windowBackground">@android:color/white</item> <item name="android:statusBarColor" >@android:color/transparent</item> <item name="android:windowLightStatusBar" tools:targetApi="23">true</item> <item name="android:windowLightNavigationBar" tools:targetApi="27">true</item> <item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="27">shortEdges</item> <item name="android:enforceStatusBarContrast" tools:targetApi="29">false</item> <item name="android:enforceNavigationBarContrast" tools:targetApi="29">false</item> </style> <style name="SplashTheme" parent="AppTheme"> <item name="android:windowFullscreen">true</item> <item name="android:windowBackground">@drawable/splash</item> <item name="android:statusBarColor" >@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:windowLightNavigationBar" tools:targetApi="27">false</item> <item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="27">shortEdges</item> <item name="android:enforceStatusBarContrast" tools:targetApi="29">false</item> <item name="android:enforceNavigationBarContrast" tools:targetApi="29">false</item> </style> </resources>
修改 app/src/main/AndroidManifest.xml 文件,将 MainActivity 的主题设置为 SplashTheme
,这是为了防止应用启动的时候有短暂的白屏。
<activity android:name=".MainActivity" android:theme="@style/SplashTheme" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity>
到目前为止,启动阶段的闪屏就已经完成了,如果你重新运行原生代码,应该可以看到闪屏一闪而过,接下来就是白屏,因为数据还没准备好,主界面还没有完成渲染。
因此我们需要加载阶段的闪屏。我们使用一个应用了 SplashTheme
的 Dialog 来实现这个阶段的闪屏,使得它看上去和启动阶段的闪屏一样。在启动阶段完成的时候,我们显示这个 Dialog,直到主界面完成渲染。
Android 加载阶段的闪屏和 hybrid-navigation
如果你使用的是 hybrid-navigationopen in new window 这款导航组件,那么实现加载阶段的闪屏非常简单。
一共只有两个步骤。
首先,编写一个应用了 SplashTheme
主题的 DialogFragment
public class SplashFragment extends AwesomeFragment { @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { setStyle(STYLE_NO_FRAME, R.style.SplashTheme); setCancelable(false); return super.onCreateDialog(savedInstanceState); } }
最后,修改 MainActivity.java 文件。
- 将 MainActity 的主题更改为
AppTheme
,这是一个 App 的正常主题。我们曾经在 AndroidManifest 中将 MainActivity 的主题设置成了SplashTheme
。 - 显示 SplashFragment 来作为加载阶段的闪屏,它看上去和启动阶段的闪屏一样。
- 当 UI 层级构建好后,再隐藏 SplashFragment,这样就可以显示主界面了。
public class MainActivity extends ReactAppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { // 更改主题为 AppTheme,这是一个 App 的正常主题 setTheme(R.style.AppTheme); super.onCreate(savedInstanceState); // 显示 SplashFragment 来作为加载阶段的闪屏 launchSplash(savedInstanceState); } @Override protected void setActivityRootFragmentSync(AwesomeFragment fragment, int tag) { super.setActivityRootFragmentSync(fragment, tag); // 此时 React Native 已经启动完成,App UI 层级已经构建好 hideSplash(); } private SplashFragment splashFragment; private void launchSplash(Bundle savedInstanceState) { if (savedInstanceState != null) { String tag = savedInstanceState.getString("splash_tag"); if (tag != null) { splashFragment = (SplashFragment) getSupportFragmentManager().findFragmentByTag(tag); } } // 当 Activity 销毁后重建,譬如旋转屏幕的时候, // 如果 React Native 已经启动完成,则不再显示闪屏 ReactContext reactContext = getCurrentReactContext(); if (splashFragment == null && reactContext == null) { splashFragment = new SplashFragment(); showAsDialog(splashFragment, 0); } } private void hideSplash() { if (splashFragment == null) { return; } // 虽然 React Native 已经启动完成,UI 层级也已经构建好, // 但主界面可能还没完成渲染,如果发现有白屏,请调整 delayInMs 参数 UiThreadUtil.runOnUiThread(() -> { if (splashFragment != null) { splashFragment.hideAsDialog(); splashFragment = null; } }, 500); } }
好,启动应用看看效果吧!
Android 加载阶段的闪屏和 react-navigation
如果你使用的是 react-navigationopen in new window 这款导航组件。
我们需要 JavaScript 告诉 Java,主界面是否已经渲染完成,因此不可避免,需要编写原生模块。
主要有以下一些步骤:
- 在 android/app/src/main/res 文件夹下创建一个名为 values-v27 的文件夹,并在其中创建一个名为 styles.xml 的文件。 对比 android/app/src/main/res/values/styles.xml 文件,只是有一行代码不同。
<resources xmlns:tools="http://schemas.android.com/tools"> <!-- Base application theme. --> <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar"> <!-- Customize your theme here. --> <item name="android:textColor">#222222</item> <item name="android:editTextBackground">@android:color/transparent</item> <item name="android:windowBackground">@android:color/white</item> <item name="android:statusBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/white</item> <item name="android:windowLightStatusBar" tools:targetApi="23">true</item> <item name="android:windowLightNavigationBar" tools:targetApi="27">true</item> <item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="27">shortEdges</item> <item name="android:enforceStatusBarContrast" tools:targetApi="29">false</item> <item name="android:enforceNavigationBarContrast" tools:targetApi="29">false</item> </style> <style name="SplashTheme" parent="AppTheme"> <item name="android:windowFullscreen">true</item> <item name="android:windowBackground">@drawable/splash</item> <item name="android:statusBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:windowLightNavigationBar" tools:targetApi="27">false</item> <item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="27">shortEdges</item> <item name="android:enforceStatusBarContrast" tools:targetApi="29">false</item> <item name="android:enforceNavigationBarContrast" tools:targetApi="29">false</item> </style> </resources>
- 编写一个应用了
SplashTheme
主题的 DialogFragment
// SplashFragment.java package com.example.rndemo; import android.app.Dialog; import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; public class SplashFragment extends DialogFragment { @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { setStyle(STYLE_NO_FRAME, R.style.SplashTheme); setCancelable(false); return super.onCreateDialog(savedInstanceState); } }
- 修改 MainActivity.java 文件。
- 将 MainActity 的主题更改为
AppTheme
,我们曾经在 AndroidManifest 中将 MainActivity 的主题设置成了SplashTheme
。 - 显示 SplashFragment 来作为加载阶段的闪屏,它看上去和启动阶段的闪屏一样。
- 提供一个隐藏 SplashFragment 的公开方法。
- 将 MainActity 的主题更改为
// MainActivity.java package com.example.rndemo; import android.os.Bundle; import com.facebook.react.ReactActivity; import com.facebook.react.bridge.ReactContext; public class MainActivity extends ReactActivity { private final static String SPLASH_TAG = "splash_tag"; private SplashFragment splashFragment; @Override protected void onCreate(Bundle savedInstanceState) { // 更改主题为 AppTheme,这是一个 App 的正常主题 setTheme(R.style.AppTheme); super.onCreate(savedInstanceState); // 显示 SplashFragment 来作为加载阶段的闪屏 showSplash(savedInstanceState); } private void showSplash(Bundle savedInstanceState) { if (savedInstanceState != null) { splashFragment = (SplashFragment) getSupportFragmentManager() .findFragmentByTag(SPLASH_TAG); } // 当 Activity 销毁后重建,譬如旋转屏幕的时候, // 如果 React Native 已经启动完成,则不再显示闪屏 ReactContext reactContext = getReactInstanceManager().getCurrentReactContext(); if (splashFragment == null && reactContext == null) { splashFragment = new SplashFragment(); splashFragment.show(getSupportFragmentManager(), SPLASH_TAG); } } public void hideSplash() { if (splashFragment != null) { splashFragment.dismiss(); splashFragment = null; } } @Override protected String getMainComponentName() { return "RnDemo"; } }
- 编写 SplashModule 用来提供 API 给 JavaScript 在恰当的时机隐藏闪屏。
// SplashModule.java package com.example.rndemo; import androidx.annotation.NonNull; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.UiThreadUtil; public class SplashModule extends ReactContextBaseJavaModule { public static final String NAME = "SplashModule"; private final ReactApplicationContext reactContext; public SplashModule(@NonNull ReactApplicationContext reactContext) { super(reactContext); this.reactContext = reactContext; } @NonNull @Override public String getName() { return NAME; } @ReactMethod public void hideSplash() { UiThreadUtil.runOnUiThread(() -> { if (!reactContext.hasActiveCatalystInstance()) { return; } MainActivity mainActivity = (MainActivity) reactContext.getCurrentActivity(); if (mainActivity != null) { mainActivity.hideSplash(); } // 虽然主界面已经 mount,但可能还没渲染成我们想要的样子, // 如果发现有白屏,请调整 delayInMs 参数 }, 500); } }
- 向 React Native 注册原生模块,这个大家都会吧。
如果忘了,可以参考 Register the Module (Android Specific)open in new window
- 来到 JavaScript 这边,封装导出的原生模块
// splash.ts import { NativeModules, NativeModule } from 'react-native' interface SplashInterface extends NativeModule { hideSplash(): void } const SplashModule: SplashInterface = NativeModules.SplashModule function hide() { SplashModule.hideSplash() } export default { hide, }
- 最后,在恰当的时机调用原生模块来隐藏闪屏
// App.tsx import Splash from '../splash' function App() { useEffect(() => { Splash.hide() }, []) return ( <SafeAreaProvider> <NavigationContainer ref={navigationRef}> <StatusBar hidden={true} translucent backgroundColor="transparent" /> <Home /> </NavigationContainer> </SafeAreaProvider> ) }
至此,加载阶段的闪屏完成。
兼容 Android 12
Android 12 引入了一个新的特性,当应用启动时,会显示一个默认的闪屏,这个闪屏是一个白色的背景,上面有应用的图标。
我们需要自定义这个闪屏,使得它看起来和启动阶段的闪屏一样。
修改 android/app/src/main/res/values/styles.xml 文件,添加如下配置
<style name="SplashTheme" parent="AppTheme"> ... <item name="android:windowSplashScreenBackground" tools:targetApi="31">#640263</item> <item name="android:windowSplashScreenAnimatedIcon" tools:targetApi="31">@mipmap/logo</item> <item name="android:windowSplashScreenBehavior" tools:targetApi="33">icon_preferred</item> </style>
修改 android/build.gradle 文件,将 targetSdkVersion
设置为 31 及以上版本,如
// android/build.gradle buildscript { ext { buildToolsVersion = "33.0.0" minSdkVersion = 21 compileSdkVersion = 33 targetSdkVersion = 33 } }
修改 android/app/src/main/AndroidManifest.xml 文件,为 MainActivity
添加 android:exported="true"
属性,如下:
<activity android:name=".MainActivity" android:exported="true" >
如果使用的导航组件是 hybrid-navigation,修改 MainActivity.java 文件,如下:
// MainActivity.java import android.os.Build; private void launchSplash(Bundle savedInstanceState) { // 添加如下代码 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { getSplashScreen().setOnExitAnimationListener(view -> { view.remove(); // 移除 fade 动画 getSplashScreen().clearOnExitAnimationListener(); }); } // ... }
如果使用的导航组件是 react-navigation,修改 MainActivity.java 文件,如下:
// MainActivity.java import android.os.Build; private void showSplash(Bundle savedInstanceState) { // 添加如下代码 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { getSplashScreen().setOnExitAnimationListener(view -> { view.remove(); // 移除 fade 动画 getSplashScreen().clearOnExitAnimationListener(); }); } // ... }
实现 iOS 闪屏
我们分别来实现启动阶段的闪屏和加载阶段的闪屏。
iOS 启动阶段的闪屏
将闪屏要用到的 2 倍图和 3 倍图一起拖到 Assets 文件夹里面。
编辑 LaunchScreen 文件,使用刚刚添加的 logo 图片来制作闪屏,这或许需要一点原生知识。
添加一个 ImageView
选中添加的 ImageView,设置它的 Image
属性为闪屏图片的名字,这里是 logo。
设置图片居中对齐
在尺寸查看器,可以调整 logo 的位置,这里让 logo 往上一点。
设置背景颜色,如图,选中作为背景的 View,打开它的属性查看器
点击上图所示的下拉按钮,在弹出的列表选项中,拉到最下面,选择自定义颜色,在弹出的颜色选择器中,选择 Color Sliders > RBG Sliders ,在 Hex Color 中填入你想要的颜色值,记得按回车键确认。
注意以下几个问题:
- Hide status bar 要勾选
- 确保 Launch Screen 文件名是 LaunchScreen
到目前为止,启动阶段的闪屏就已经完成了,如果你重新运行原生代码,应该可以看到闪屏一闪而过,接下来就是白屏,因为数据还没准备好,主界面还没有完成渲染。
因此我们需要加载阶段的闪屏。我们使用一个 UIViewController 来加载 LaunchScreen,使得它看上去和启动阶段的闪屏一样。在启动阶段完成的时候,我们马上显示这个 UIViewController,直到主界面完成渲染。
iOS 加载阶段的闪屏和 hybrid-navigation
如果你使用的是 hybrid-navigationopen in new window 这款导航组件,那么实现加载阶段的闪屏非常简单。就像开发原生 App 那样做。
修改 AppDelegate.m 文件
@implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions]; [[HBDReactBridgeManager get] installWithBridge:bridge]; // 加载 `LaunchScreen`,将它作为 `UIViewController` 的 `view` UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"LaunchScreen" bundle:nil]; UIViewController *rootViewController = [storyboard instantiateInitialViewController]; self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; self.window.windowLevel = UIWindowLevelAlert + 4; self.window.rootViewController = rootViewController; // 一直显示加载阶段的闪屏,直到 React Native 启动完成, // hybrid-navigation 会将 window 的 rootViewController 替换为主界面。 [self.window makeKeyAndVisible]; return YES; } @end
好,启动应用看看效果吧!
iOS 加载阶段的闪屏和 react-navigation
如果你使用的是 react-navigationopen in new window 这款导航组件。
我们需要 JavaScript 告诉 ObjC,主界面是否已经渲染完成,因此不可避免,需要编写原生模块。
主要有以下一些步骤:
- 创建一个名为 SplashWindow 的类,它负责显示和隐藏闪屏。
// SplashWindow.h #import <UIKit/UIKit.h> NS_ASSUME_NONNULL_BEGIN // 这个协议被 AppDelegate 实现,被 SplashModule 使用 @protocol SplashDelegate <NSObject> - (void)hideSplash; @end @interface SplashWindow : UIWindow - (void)show; - (void)hide:(void (^)(BOOL finished))completion; @end NS_ASSUME_NONNULL_END
show
方法将 LaunchScreen 作为闪屏显示,这就使得加载阶段的闪屏和启动阶段的闪屏看起来是一样的。
hide
方法则通过淡入淡出的动画来隐藏闪屏和显示主界面,使得过渡更加平滑。
// SplashWindow.m #import "SplashWindow.h" @implementation SplashWindow - (void)show { // 加载 `LaunchScreen`,将它作为 `UIViewController` 的 `view` UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"LaunchScreen" bundle:nil]; UIViewController *splash = [storyboard instantiateInitialViewController]; self.rootViewController = splash; self.backgroundColor = UIColor.clearColor;// 避免横竖屏旋转时出现黑色 self.windowLevel = UIWindowLevelAlert + 4; // 一直显示加载阶段的闪屏,直到 `hide` 方法被调用 [self makeKeyAndVisible]; } - (void)hide:(void (^)(BOOL finished))completion { UIWindow *mainWindow = [UIApplication sharedApplication].delegate.window; [UIView transitionWithView:mainWindow duration:0.35f options:UIViewAnimationOptionTransitionCrossDissolve animations:^{ // 显示主界面 [[UIApplication sharedApplication].delegate.window makeKeyAndVisible]; } completion:completion]; } @end
- 修改 AppDelegate.h 文件,声明遵循 SplashDelegate 协议
#import <React/RCTBridgeDelegate.h> #import <UIKit/UIKit.h> #import "SplashWindow.h" // 声明遵循 SplashDelegate 协议 @interface AppDelegate : UIResponder <UIApplicationDelegate, RCTBridgeDelegate, SplashDelegate> @property (nonatomic, strong) UIWindow *window; @end
- 修改 AppDelegate.m 文件,创建 SplashWindow 实例,并将其作为 key window 显示,这样就可以显示加载阶段的闪屏了,它看起来和启动阶段的闪屏一样。另外,AppDelegate.m 实现了 SplashDelegate 协议的
hideSplash
方法,实际会调用到 SplashWindow 的hide
方法。
#import "AppDelegate.h" + @interface AppDelegate () + @property (nonatomic, strong) SplashWindow *splashWindow; + @end - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.window.rootViewController = rootViewController; - [self.window makeKeyAndVisible]; + self.splashWindow = [[SplashWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + [self.splashWindow show]; return YES; } + - (void)hideSplash { + if (self.splashWindow != nil) { + [self.splashWindow hide:^(BOOL finished) { + self.splashWindow = nil; + }]; + } + }
- 创建一个名为 SplashModule 的原生模块,它向 JavaScript 提供 API,用于隐藏闪屏。
// SplashModule.h #import <React/RCTBridgeModule.h> NS_ASSUME_NONNULL_BEGIN @interface SplashModule : NSObject<RCTBridgeModule> @end NS_ASSUME_NONNULL_END
SplashModule 的 hideSplash
方法通过 [UIApplication sharedApplication].delegate
找到 AppDelegate 实例,并通过其遵循的 SplashDelegate 协议,来实现隐藏闪屏的功能。
// SplashModule.m #import "SplashModule.h" #import "SplashWindow.h" #import <UIKit/UIKit.h> @implementation SplashModule RCT_EXPORT_MODULE(SplashModule); + (BOOL)requiresMainQueueSetup { return YES; } - (dispatch_queue_t)methodQueue { return dispatch_get_main_queue(); } RCT_EXPORT_METHOD(hideSplash) { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ id<UIApplicationDelegate> appDelegate = [UIApplication sharedApplication].delegate; if ([appDelegate conformsToProtocol:@protocol(SplashDelegate)]) { [(id<SplashDelegate>)appDelegate hideSplash]; } }); } @end
- 来到 JavaScript 这边,包装导出的原生模块
// splash.ts import { NativeModules, NativeModule } from 'react-native' interface SplashInterface extends NativeModule { hideSplash(): void } const SplashModule: SplashInterface = NativeModules.SplashModule function hide() { SplashModule.hideSplash() } export default { hide, }
- 最后,在恰当的时机调用原生模块来隐藏闪屏
// App.tsx import Splash from '../splash' function App() { useEffect(() => { Splash.hide() }, []) return ( <SafeAreaProvider> <NavigationContainer ref={navigationRef}> <StatusBar hidden={true} translucent backgroundColor="transparent" /> <Home /> </NavigationContainer> </SafeAreaProvider> ) }
示例
这里有一个示例open in new window,供你参考。