闪屏是 App 启动时的一种特殊的界面,它可以在 App 启动时显示一个加载动画,并且可以让用户知道 App 正在加载。
闪屏包含两个阶段,分别是启动阶段和加载阶段。启动阶段是指 App 开始启动到启动完成,但是数据还没准备好,主界面还没完全显示的阶段。加载阶段是指 App 准备数据,到主界面渲染完成的阶段。
这两个阶段需要分别设置两个不同的闪屏,但在用户看来,闪屏是一样的。
任何一个阶段没处理好,都有可能出现白屏的情况。先不说 JavaScript Bundle 的加载需要时间,光是启动阶段,JavaScript 就无法参与。因此,闪屏必须放到原生侧来实现。
作为一名熟悉原生开发的工程师,实现闪屏是必备技能,是比较简单的。
本文详细介绍了如何基于原生实现闪屏,给那些不太熟悉原生开发的小伙伴提供一个参考。
实现 Android 闪屏
我们分别来实现启动阶段的闪屏和加载阶段的闪屏。
Android 启动阶段的闪屏
将闪屏要用到的两倍图和三倍图分别放到 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 android:bottom="60dp">
<bitmap
android:antialias="true"
android:dither="true"
android:filter="true"
android:gravity="center"
android:src="@mipmap/logo" />
</item>
</layer-list>
注意第 5 行是闪屏的背景颜色,第 15 行是闪屏的 logo,可以通过第 9 行来调整 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-navigation 这款导航组件,那么实现加载阶段的闪屏非常简单。
一共只有两个步骤。
首先,编写一个应用了 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-navigation 这款导航组件。
我们需要 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 的公开方法。
-
// 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)
- 来到 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>
)
}
至此,加载阶段的闪屏完成。
实现 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-navigation 这款导航组件,那么实现加载阶段的闪屏非常简单。就像开发原生 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-navigation 这款导航组件。
我们需要 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>
)
}
示例
这里有一个示例,供你参考。