개발지식 정리/React Native

React Native 라이브러리 없이 SplashScreen 구현하기(iOS 네이티브 모듈)

Neoself 2024. 7. 12. 14:05

React Native 앱의 경우 대표적으로 react-native-splash-screen 라이브러리를 통해 초기 SplashScreen 구현이 가능하다. 하지만 라이브러리들을 일괄적으로 업데이트할때마다 각 라이브러리의 네이티브 코드끼리 충돌이 생기는 이슈를 겪으면서, 라이브러리들을 자체적으로 모듈화하는 것이 유지보수에 더 용이할 것이라는 판단 하에, react-native-splash-screen 라이브러리의 대체방안을 모색하게 되었다.

 

가장 먼저 시도한 방안은 iOS가 기본 제공하는 LaunchScreen으로 SplashScreen을 구현하는 것이였다.

 

하지만, React Native로 구현된 앱의 경우 네이티브 앱과 달리 앱 초기 로드 간에 추가적인 단계가 필요하기에 일반적으로 더 많은 시간이 소요된다. 따라서, 네이티브 앱 생명주기만 고려된 LaunchScreen을 별도 조작없이 사용하게 될 경우, UI 렌더링이 완료되지 않은 시점에 SplashScreen이 내려가 빈 화면이 일시적으로 보이는 White Flash 현상을 확인할 수 있었다.

 

White Flash 현상

 

 

따라서 라이브러리를 사용하지 않고, SplashScreen이 올바른 시점에 내려가게 할 수 있는 방안은 크게 2가지가 존재한다.

  1. LaunchScreen이 사라지는 시점을 의도적으로 딜레이 시켜 Splash Screen 구현
  2. LaunchScreen이 사라지는 시점을 Javascript 단에서 직접 조작할 수 있는 네이티브 모듈 구현

1번의 경우, 네이티브와 Javascript 간의 통신이 불필요하기 때문에 굉장히 쉽게 구현이 가능하다. 하지만, 기기사양에 따라 로드 완료 시점이 다르기에 최적의 앱 시작 시간을 보장해주지 않으며, 최악의 경우 WhiteFlash를 해결하지 못할 수도 있다. 하지만, 2번의 경우 앱 로딩이 완료된 이후에 실행되는 Javascript 단과 통신하기 때문에 정확한 앱 로드완료 시점을 파악할 수 있다. 따라서 2개 방법 중 자신의 상황에 맞는 LaunchScreen을 구현하면 되겠다.

 

본 게시글은 2번째 방안 즉, LaunchScreen 네이티브 모듈 구현방법을 다루고 있다. 만일 1번째 방법으로 SplashScreen을 구현하고자 할 경우, 다음 포스트를 확인하면 된다.

https://neoself.tistory.com/7

 

React Native 라이브러리 없이 SplashScreen 구현하기(iOS LaunchScreen)

React Native 앱의 경우 대표적으로 react-native-splash-screen 라이브러리를 통해 초기 SplashScreen 구현이 가능하다. 하지만 라이브러리들을 일괄적으로 업데이트할때마다 각 라이브러리의 네이티브 코드

neoself.tistory.com

 


 

네이티브 단을 구현하기에 앞서 기존에 사용했던 react-native-splash-screen 라이브러리의 깃허브 코드를 통해 동작 원리를 분석해보았다. 그 결과 위 라이브러리 또한 Javascript에서 호출 가능한 hide 함수를 네이티브 모듈을 통해 구현한 것을 확인할 수 있었다.

 

- 라이브러리 깃허브 코드

더보기
더보기
// AppDelegate.m
@implementation AppDelegate
// 앱이 시작될때 호출되는 함수
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  // RCTRootView 클래스를 상송받는 rootView 생성
  RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge
                                                   moduleName:@"examples"
                                            initialProperties:nil];
  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  UIViewController *rootViewController = [UIViewController new];
  rootViewController.view = rootView;
  self.window.rootViewController = rootViewController;
  // hide 함수가 Javascript 단에서 호출되지 않으면, 16번째 줄에서 계속 RunLoop이 실행되어,
  // didFinishLaunchingWithOptions가 종료되지 않음 -> SplashScreen 가려지지 않음.
  [RNSplashScreen show];
  return YES;
}

 

// RNSplashScreen.m

static bool waiting = true;
static bool addedJsLoadErrorObserver = false;
static UIView* loadingView = nil;


+ (void)show {
    if (!addedJsLoadErrorObserver) {
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(jsLoadError:) name:RCTJavaScriptDidFailToLoadNotification object:nil];
        addedJsLoadErrorObserver = true;
    }

    while (waiting) {
        NSDate* later = [NSDate dateWithTimeIntervalSinceNow:0.1];
        [[NSRunLoop mainRunLoop] runUntilDate:later];
    }
}

+ (void) jsLoadError:(NSNotification*)notification
{
    // If there was an error loading javascript, hide the splash screen so it can be shown.  Otherwise the splash screen will remain forever, which is a hassle to debug.
    [RNSplashScreen hide];
}

RCT_EXPORT_METHOD(hide) {
    [RNSplashScreen hide];
}

RCT_EXPORT_METHOD(show) {
    [RNSplashScreen show];
}

Apple의 경우 사용자가 메인 스크린을 최대한 빨리 그리고 자연스럽게 접근하게 하기 위해, LaunchScreen을 가볍게 설계하는 것을 권장한다. 이를 방증하듯 iOS에서는 LaunchScreen을 유지하는 사전 정의 메서드가 따로 없다. Stack Overflow에서는 이에 대한 차선책으로 기존 LaunchScreen storyboard를 위한 ViewController를 별도 생성한 후, View 컴포넌트처럼 수동적으로 타임 딜레이를 부여해주는 것을 제시하고 있는데, RNSplashScreen 클래스의 show 메서드가 이 로직과 유사하게 구현되어있음을 확인할 수 있다.

 

초기 앱 실행 시, iOS 단에서 실행되는 didFinishLaunchingWithOptions 함수 내부에 show 메서드를 호출한 것을 볼 수 있는데,  이로 인해 앱 실행과 동시에 자동으로 스플래시 스크린이 띄워진 직후, [RNSplashScreen show]가 메인 스레드를 계속 가져가 hide 함수로 waiting 전역변수가 false로 토글되기 전까지 0.1초 단위로 waiting 변수 상태를 확인하는 것만을 진행하게 된다.

 

기존 XCode 인터페이스 상에서 LaunchScreen 스토리보드를 생성하고 연결하는 과정은 건들지 않았기에, Apple에서 사전정의한 LaunchScreen 생명주기를 건들지는 않았지만, LaunchScreen이 자동으로 내려가는 시점 직전에 의도적으로 메인스레드를 가져가 네이티브 단의 생명주기를 잠시 멈추었기에, 사용자 시점에서는 LaunchScreen이 사라지는 시점을 조작한 것과 유사한 효과를 얻게 되는 것이다.

 

하지만, 위 코드의 경우 hide 함수가 호출되기 전까지는 메인스레드를 사용하지 못하는만큼, 앱 실행 도중 네이티브 UI작업을 위한 초기 로딩이 필수적일 때 사용자 경험을 악화시킬 수 있으며, 0.1초 단위로 계속 변수값을 확인하는 로직인 만큼, 불필요하게 앱이 무거워지는 요인중 하나가 될 것이라 판단했다. 

 


 

따라서, 위 방식과는 다른 접근방식을 모색하였으며, 그 결과 아래 iOS 블로그 글을 찾을 수 있었다.

https://staktree.github.io/ios/IOS-splash-02/

 

[IOS]스플래시 이미지 적용#02 - 커스텀 뷰 컨트롤러를 사용하여 애니메이션까지!

블로그 이전 : https://staktree.tistory.com/

staktree.github.io

 

기본 LaunchScreen 자체를 조작하는 것이 불가능하기에, LaunchScreen과 동일한 디자인의 서브 뷰를 위에 생성한 후, 해당 서브 뷰의 hide 시점을 조작해 커스텀 LaunchScreen과 동일한 효과를 가져가는 방법을 소개하고 있는데, 이의 경우 기본 LaunchScreen이 Apple의 생명주기로 인해 자동으로 hide되어도, 동일한 화면이 렌더를 유지하고 있기에 LaunchScreen이 계속 표시되는 것처럼 사용자를 속일 수 있다는 것이었다. 

 

1. 초기 로직 대비 실행되는 함수가 더 적다.

2. 메인스레드를 상시 가져가지 않기에 성능상 더 유리하다.

3. React Native 프레임워크에서 제공하는 RCTRootView 클래스 내부에 선언된 showLoadingView 메서드와 로직이 유사하다.

 

상기 3가지 근거 하에, 위 로직을 Swift 클래스로 직접 구현해보기로 하였다. 물론, RCTRootView 클래스 내부 메서드를 통해 loadingView를 표시하고 조작하는 방안도 가능하지만, RCTRootView 클래스 파일 전체를 import해 사용하는 것보다 필요한 기능만 Root 단에서 직접 구현해놓는 것이 성능과 유지보수에 유리할 것이라 판단해 직접 구현을 택하였다.

 

import React
import UIKit
import Foundation
// Javascript 로드간에 에러를 감지하는 알림을 수신하는 Observer 추가 위해 확인되는 boolean값
private var addedJsLoadErrorObserver = false
// LaunchScreen과 동일한 디자인의 SubView
private var loadingView: UIView? 
// 초기에는 undefined값
private var superView : UIView?
// LaunchScreen의 Fade transition 소요시간
private var loadingViewFadeDuration = 0.5;

class SplashScreen: NSObject {
  
  @objc class func hide() {
    // UIView.transition 함수를 통해 SubView가 0.5초에 걸쳐 서서히 사라지게 설정
    DispatchQueue.main.asyncAfter(deadline: .now()) {
      UIView.transition( with: superView!,
                         duration: loadingViewFadeDuration,
                         options:.transitionCrossDissolve,
                         animations: { loadingView?.alpha=0 },
                         completion: { _ in
        loadingView?.removeFromSuperview() })
    }
  }
  
  @objc class func show(_ rootView: UIView) {
    // AppDelegate에서 선언한 rootView 가져와 superView 전역변수에 저장
    superView = rootView
    let launchScreen = UIStoryboard(name: "LaunchScreen", bundle: nil).instantiateInitialViewController()
    loadingView = launchScreen?.view
    // 1. 디렉토리에 있는 LaunchScreen 파일로 launchScreen 스토리보드 객체 생성
    // 2. 스토리보드 객체의 하위 view를 클래스 변수인 loadingView에 저장
    // 3. 앞서 가져온 rootView의 SubView로 loadingView 위에 렌더
    rootView.addSubview(loadingView!)
    
    // Javascript 로드실패 여부 Observer 추가 안되었을 경우, 추가
    if !addedJsLoadErrorObserver {
      NotificationCenter.default.addObserver(self, selector: #selector(jsLoadError(_:)), name: Notification.Name.RCTJavaScriptDidFailToLoad, object: nil)
      addedJsLoadErrorObserver = true
    }
  }
  
  // Observer에서 JSLoadError 감지되면 호출되는 함수, 에러 감지되면 SplashScreen hide
  @objc class func jsLoadError(_ notification: Notification) {
    hide()
  }
}

// JS 단에서는 hide 함수만 사용되므로 hide()함수만 노출
@objc(SplashScreenModule)
class SplashScreenModule: NSObject {
  @objc 
  class func requiresMainQueueSetup() -> Bool {
    // 네이티브 UI 뷰로 실시간 업데이트가 필요하기에 메인스레드 Setup 필요 명시
    return true;
  }
  
  @objc func hide() {
    SplashScreen.hide()
  }
}
// AppDelegate.mm

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  self.moduleName = @"exampleApp";
  self.initialProps = @{};
  
  BOOL didFinish = [super application:application didFinishLaunchingWithOptions:launchOptions];
  // rootViewController 객체의 view를 UIView 클래스의 rootView로 저장
  UIView *rootView = (UIView *)self.window.rootViewController.view;
  // 새로 생성한 SplashScreen 클래스의 show 함수 인자로 앞서 생성한 rootView 전달
  [SplashScreen show:rootView];
  
  return didFinish;
}

 

물론 Swift 파일을 React Native에서 읽을 수 있게 하는 Bridging Header 파일과 SplashScreenModule 클래스 내부 함수 및 모듈을 노출시키는 SplashScreenModule.m 파일도 추가되어야 한다. Bridging Header 파일과 Module.m 파일에 대한 자세한 내용은 아래 포스트에서 확인할 수 있다.

https://neoself.tistory.com/4

 

React Native에 네이티브 모듈 연결하기 - 파노라마 뷰(iOS)

본 게시글은 iOS에서의 Native 모듈 연결과정을 담고 있으며, 사용할 예시로 이전에 구현하였던 파노라마뷰 모듈을 사용할 것이다. 네이티브 UI뷰 파일이 React Native에 렌더되기까지 일반적으로 필

neoself.tistory.com

 

 

- Bridging Header 파일 및 SplashScreenModule.m 파일 내용

더보기
더보기
// exampleApp-Bridging-Header.h

#import <React/RCTBridgeModule.h>
// SplashScreenModule.m

#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(SplashScreenModule, NSObject)
  RCT_EXTERN_METHOD(hide);
@end

이후 마지막으로, JS단에서 react-native에서 제공하는 NativeModules 메서드를 통해 hide 함수를 호출하면 SplashScreen을 원하는 시점에 사라지게 조작이 가능하다.

import { NativeModules } from 'react-native';

export const hideSplashScreen = () => {
  NativeModules.SplashScreenModule.hide();
};


const setup = async () => {
    if (isJavascriptReady) {
      hideSplashScreen();
    }
}

테스트 앱 실행 장면

 


react-native-splash-screen  VS  iOS Native Module

각 로직에서의 LaunchScreen 표시 및 가리기 기능을 담당하는 함수 내부에 LaunchScreen이 가려지기까지 소요되는 시간을 측정하는 로직을 추가해 성능을 비교해보았다. 측정 시점에서의 컴퓨터 메모리환경, 빌드 관련 캐시들과 같은 변수들을 최소화하기 위해 매 측정마다 빌드폴더를 Clean 하였으며, 라이브러리, 네이티브 모듈을 번갈아가며 총 5회의 측정결과들을 평균내어 최종결과를 계산하였다.

 

- 시간측정 로직

더보기
더보기
// exampleApp/SplashScreenModule.m

@objc class func show(_ rootView: UIView) {
    // show 함수 호출 시 Date 객체 생성
    startDate = Date()
    superView = rootView
    let launchScreen = UIStoryboard(name: "LaunchScreen", bundle: nil).instantiateInitialViewController()
    loadingView = launchScreen?.view
    rootView.addSubview(loadingView!)
    
    if !addedJsLoadErrorObserver {
      NotificationCenter.default.addObserver(self, selector: #selector(jsLoadError(_:)), name: Notification.Name.RCTJavaScriptDidFailToLoad, object: nil)
      addedJsLoadErrorObserver = true
    }
  }
  
  @objc class func hide() {
    // hide 함수 호출 시 timeInterval 로그로 표시
    print("Elapsed Time for Launch using iOS Module: " + String(Date().timeIntervalSince(startDate!)*1000))
    
    DispatchQueue.main.asyncAfter(deadline: .now()) {
      UIView.transition( with: superView!,
                         duration: loadingViewFadeDuration,
                         options:.transitionCrossDissolve,
                         animations: { loadingView?.alpha=0 },
                         completion: { _ in
        loadingView?.removeFromSuperview() })
    }
  }
// react-native-splash-screen/RNSplashScreen.m

+ (void)show {
    // 첫 show 함수 호출 시, NSDate 객체 생성
    if (!started){
        date = [NSDate date];
        started = true;
    }
    if (!addedJsLoadErrorObserver) {
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(jsLoadError:) name:RCTJavaScriptDidFailToLoadNotification object:nil];
        addedJsLoadErrorObserver = true;
    }
    while (waiting) {
        NSDate* later = [NSDate dateWithTimeIntervalSinceNow:0.1];
        [[NSRunLoop mainRunLoop] runUntilDate:later];
    }
}

+ (void)hide {
    if (waiting) {
        dispatch_async(dispatch_get_main_queue(), ^{
            waiting = false;
        });
        // timeInterval 로그로 표시
        NSTimeInterval elapsedTime = [date timeIntervalSinceNow] * -1000.0;
        NSLog(@"Elapsed Time for Launch using lib: %f",elapsedTime);
    } else {
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            [loadingView removeFromSuperview];
        });
    }
}

 

react-native-splash-screen를 제거하고 자체 iOS Module로 구현한 결과, 앱 실행부터 메인화면이 보여지기까지 약 0.14초를 줄일 수 있었다.


Reference


https://developer.apple.com/design/human-interface-guidelines/launching#Launch-screens

 

Launching | Apple Developer Documentation

A streamlined launch experience helps people start using your app or game immediately.

developer.apple.com

https://stackoverflow.com/questions/43276199/how-can-i-delay-splash-launch-screen-programmatically-in-swift-xcode-ios

 

How can I delay splash launch screen programmatically in Swift Xcode iOS

I have put an image in imageView in LaunchStoreyboard. How can I delay the time of image programmatically? Here is the Launch Screen Guideline from Apple. Here is code for Launch Screen View cont...

stackoverflow.com