반응형
Notice
Recent Posts
Recent Comments
Link
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Archives
Today
Total
관리 메뉴

코딩하기 좋은날

Deep Dive into Splash Screen - 2 본문

Android

Deep Dive into Splash Screen - 2

huiung 2022. 5. 8. 16:44
반응형

없앨 수 있을까?

대부분의 서비스들은 각자의 Splash화면을 기존에 구현한 상태기 때문에, 해당 화면이 뜨는 것을 원치 않을 수 있다. 공식문서에서 해당 화면을 사용하지 않는 옵션은 제공되지 않고 있다. Android12의 소스코드를 분석해보며 해당 SplashScreen을 제거 할 수 있는지 확인해보자. 해당 화면이 언제 어디에 붙고 어떻게 제거 되는지 실제 소스코드를 분석해볼 것이다.

 

Activity 호출 프로세스

 

위의 SequenceDiagram은 Activity호출 프로세스를 보여주고 있다. ActivityManagerService(AMS)에 의해 시작되며 Task에서 ActivityRecord의 showStartingWindow()의 호출로 startingWindow를 보여주기 시작한다.

– StartingWindow(Preview Window)란? : 하드웨어와 소프트웨어의 발전으로 로딩속도는 매우 빨라졌지만, 그래도 부드럽지 못하게 화면이 전환되는 현상을 아직까지는 피하기 어렵다. 그래서 "starting window" 라고 불리는 임시 윈도우를 preview window 의 개념으로 보여준다.

 

ActivityRecord.java

1void showStartingWindow(ActivityRecord prev, boolean newTask, boolean taskSwitch,
2            boolean startActivity, ActivityRecord sourceRecord) {
3
4    ...
5
6    final int splashScreenTheme = startActivity ? getSplashscreenTheme() : 0;
7    final int resolvedTheme = evaluateStartingWindowTheme(prev, packageName, theme,
8            splashScreenTheme);
9
10    ...
11
12    final boolean shown = addStartingWindow(packageName, resolvedTheme,
13            compatInfo, nonLocalizedLabel, labelRes, icon, logo, windowFlags,
14            prev != null ? prev.appToken : null,
15            newTask || newSingleActivity, taskSwitch, isProcessRunning(),
16            allowTaskSnapshot(), activityCreated, mSplashScreenStyleEmpty);
17    if (shown) {
18        mStartingWindowState = STARTING_WINDOW_SHOWN;
19    }
20}
21
22private int evaluateStartingWindowTheme(ActivityRecord prev, String pkg, int originalTheme,
23            int replaceTheme) {
24// Skip if the package doesn't want a starting window.
25    if (!validateStartingWindowTheme(prev, pkg, originalTheme)) {
26        return 0;
27    }
28    int selectedTheme = originalTheme;
29    if (replaceTheme != 0 && validateStartingWindowTheme(prev, pkg, replaceTheme)) {
30// allow to replace theme
31        selectedTheme = replaceTheme;
32    }
33    return selectedTheme;
34}
35
36private boolean validateStartingWindowTheme(ActivityRecord prev, String pkg, int theme) {
37// If this is a translucent window, then don't show a starting window -- the current
38// effect (a full-screen opaque starting window that fades away to the real contents
39// when it is ready) does not work for this.
40    ProtoLog.v(WM_DEBUG_STARTING_WINDOW, "Checking theme of starting window: 0x%x", theme);
41    if (theme == 0) {
42        return false;
43    }
44
45    ...
46
47    final boolean windowIsTranslucent = ent.array.getBoolean(
48            com.android.internal.R.styleable.Window_windowIsTranslucent, false);
49    final boolean windowIsFloating = ent.array.getBoolean(
50            com.android.internal.R.styleable.Window_windowIsFloating, false);
51    final boolean windowShowWallpaper = ent.array.getBoolean(
52            com.android.internal.R.styleable.Window_windowShowWallpaper, false);
53    final boolean windowDisableStarting = ent.array.getBoolean(
54            com.android.internal.R.styleable.Window_windowDisablePreview, false);
55    ProtoLog.v(WM_DEBUG_STARTING_WINDOW,
56            "Translucent=%s Floating=%s ShowWallpaper=%s Disable=%s",
57            windowIsTranslucent, windowIsFloating, windowShowWallpaper,
58            windowDisableStarting);
59// If this activity is launched from system surface, ignore windowDisableStarting
60    if (windowIsTranslucent || windowIsFloating) {
61        return false;
62    }
63    if (windowShowWallpaper
64            && getDisplayContent().mWallpaperController.getWallpaperTarget() != null) {
65        return false;
66    }
67    if (windowDisableStarting && !launchedFromSystemSurface()) {
68// Check if previous activity can transfer the starting window to this activity.
69        return prev != null && prev.getActivityType() == ACTIVITY_TYPE_STANDARD
70                && prev.mTransferringSplashScreenState == TRANSFER_SPLASH_SCREEN_IDLE
71                && (prev.mStartingData != null
72                || (prev.mStartingWindow != null && prev.mStartingSurface != null));
73    }
74    return true;
75}

SplsahScreenTheme 값을 얻어온뒤 addStartingWindow()함수를 호출하고 있다. 여기서 splashScreenTheme를 evaluateStartingWindowTheme로 넘겨주고 있는데, 이 함수 내부를 보면 startingWindow를 사용할지 말지를 결정할 수 있다. validateStartingWindowTheme() 의 결과로 false가 리턴된다면, resolvedTheme의 값이 0이 되고, startingWindow를 skip 할 수 있다. validateStartingWindowTheme() 내부를 보면, 간단하게 false를 리턴하는 방법은 windowIsTranslucent 또는 windowIsFloating 값을 true로 주는 것이다. 해당 옵션중 하나라도 true 값을 주게 된다면 스플래시 스크린은 나타나지 않게 된다!

 

ActivityRecord.java

1boolean addStartingWindow(String pkg, int resolvedTheme, CompatibilityInfo compatInfo,
2            CharSequence nonLocalizedLabel, int labelRes, int icon, int logo, int windowFlags,
3            IBinder transferFrom, boolean newTask, boolean taskSwitch, boolean processRunning,
4            boolean allowTaskSnapshot, boolean activityCreated, boolean useEmpty) {
5
6    ...
7
8// Original theme can be 0 if developer doesn't request any theme. So if resolved theme is 0
9// but original theme is not 0, means this package doesn't want a starting window.
10    if (resolvedTheme == 0 && theme != 0) {
11        return false;
12    }
13    applyStartingWindowTheme(pkg, resolvedTheme);
14
15    ...
16
17    ProtoLog.v(WM_DEBUG_STARTING_WINDOW, "Creating SplashScreenStartingData");
18    mStartingData = new SplashScreenStartingData(mWmService, pkg,
19            resolvedTheme, compatInfo, nonLocalizedLabel, labelRes, icon, logo, windowFlags,
20            getMergedOverrideConfiguration(), typeParameter);
21    scheduleAddStartingWindow();
22    return true;
23}
24
25void scheduleAddStartingWindow() {
26    if (StartingSurfaceController.DEBUG_ENABLE_SHELL_DRAWER) {
27        mAddStartingWindow.run();
28    } else {
29        if (!mWmService.mAnimationHandler.hasCallbacks(mAddStartingWindow)) {
30            ProtoLog.v(WM_DEBUG_STARTING_WINDOW, "Enqueueing ADD_STARTING");
31            mWmService.mAnimationHandler.postAtFrontOfQueue(mAddStartingWindow);
32        }
33    }
34}

 

StartingData에 SplashScreenStartingData를 만들어서 넣어 준 뒤, scheduleAddStartingWindow()를 호출한다. scheduleAddStartingWindow()내부에서는 AddStartingWindow의 run()을 실행시키며 아래와 같이 startingData의 createStartingSurface를 호출하여 생성한다.

 

1private class AddStartingWindow implements Runnable {
2
3  @Override
4  public void run() {
5      ...
6      WindowManagerPolicy.StartingSurface surface = null;
7      try {
8          surface = startingData.createStartingSurface(ActivityRecord.this);
9      } catch (Exception e) {
10          Slog.w(TAG, "Exception when adding starting window", e);
11      }
12      ...

SplashScreenStartingData.createStartingSurface →

StartingSurfaceController.createSplashScreenStartingSurface →

PhoneWindowManager.addSplashScreen 순서로 호출스택이 이어지게 된다.

 

PhoneWindowManager.java

1@Override
2    public StartingSurface addSplashScreen(IBinder appToken, int userId, String packageName,
3            int theme, CompatibilityInfo compatInfo, CharSequence nonLocalizedLabel, int labelRes,
4            int icon, int logo, int windowFlags, Configuration overrideConfig, int displayId) {
5    ...
6
7    try {
8        Context context = mContext;
9
10// Obtain proper context to launch on the right display.
11        final Context displayContext = getDisplayContext(context, displayId);
12        if (displayContext == null) {
13// Can't show splash screen on requested display, so skip showing at all.
14            return null;
15        }
16        context = displayContext;
17
18        ...
19
20//PhoneWindow 생성
21        final PhoneWindow win = new PhoneWindow(context);
22        win.setIsStartingWindow(true);
23
24        ...
25
26//Window에 ContentView 추가
27        addSplashscreenContent(win, context);
28
29        wm = (WindowManager) context.getSystemService(WINDOW_SERVICE);
30        view = win.getDecorView();
31
32
33//WindowManager에 PhoneWindow의 decorView add
34        wm.addView(view, params);
35
36// Only return the view if it was successfully added to the
37// window manager... which we can tell by it having a parent.
38        return view.getParent() != null ? new SplashScreenSurface(view, appToken) : null;
39    }
40
41    ...
42
43    return null;
44}

addSplashScreenContent를 호출하여 window의 contentView를 추가하고, windowManager에 window의 decorView를 add하고 있다.

StartingWindow의 호출 프로세스를 정리하자면 아래와 같다.

 

StartingWindow 호출 프로세스

 

 

다음은 StartingWindow가 제거되는 과정이다. Activity window를 로드하여 표시하기 전에 StartingWindow를 제거해준다.

 

WindowState.java

1boolean performShowLocked() {
2    ...
3
4    final int drawState = mWinAnimator.mDrawState;
5    if ((drawState == HAS_DRAWN || drawState == READY_TO_SHOW) && mActivityRecord != null) {
6        if (mAttrs.type != TYPE_APPLICATION_STARTING) {
7            mActivityRecord.onFirstWindowDrawn(this, mWinAnimator);
8        } else {
9            mActivityRecord.onStartingWindowDrawn();
10        }
11    }
12
13    ...
14    return true;
15}

drawState가 HAS_DRAWN 또는 READY_TO_SHOW로 변하면, ActivityRecord의 onFirstWindowDrawn()이 호출된다.

 

ActivityRecord.java

1 void onFirstWindowDrawn(WindowState win, WindowStateAnimator winAnimator) {
2    firstWindowDrawn = true;
3// stop tracking
4    mSplashScreenStyleEmpty = true;
5
6// We now have a good window to show, remove dead placeholders
7    removeDeadWindows();
8
9    if (mStartingWindow != null) {
10        ProtoLog.v(WM_DEBUG_STARTING_WINDOW, "Finish starting %s"
11                + ": first real window is shown, no animation", win.mToken);
12// If this initial window is animating, stop it -- we will do an animation to reveal
13// it from behind the starting window, so there is no need for it to also be doing its
14// own stuff.
15        win.cancelAnimation();
16    }
17    removeStartingWindow();
18    updateReportedVisibilityLocked();
19}
20
21void removeStartingWindow() {
22    removeStartingWindowAnimation(true/* prepareAnimation */);
23}
24
25void removeStartingWindowAnimation(boolean prepareAnimation) {
26  if (transferSplashScreenIfNeeded()) {
27      return;
28  }
29  mTransferringSplashScreenState = TRANSFER_SPLASH_SCREEN_IDLE;
30  ...
31
32  final Runnable removeSurface = () -> {
33        ProtoLog.v(WM_DEBUG_STARTING_WINDOW, "Removing startingView=%s", surface);
34        try {
35            surface.remove(prepareAnimation && startingData.needRevealAnimation());
36        } catch (Exception e) {
37            Slog.w(TAG_WM, "Exception when removing starting window", e);
38        }
39    };
40
41    ...
42}
43
44//SplashScreenSurface.java
45@Override
46public void remove(boolean animate) {
47    if (DEBUG_SPLASH_SCREEN) Slog.v(TAG, "Removing splash screen window for " + mAppToken + ": "
48                    + this + " Callers=" + Debug.getCallers(4));
49
50    final WindowManager wm = mView.getContext().getSystemService(WindowManager.class);
51    wm.removeView(mView);
52}

FirstWindow가 그려지기 시작하면 removeStartingWindow가 호출되며 이후 SplashScreenSurface.remove()를 호출하여 windowManager에서 StartingWindow의 decorView를 제거한다.

 

StartingWindow 제거 프로세스

 

 

removeStartingWindowAnimation() 내부에서 transferSplashScreenIfNeeded() 라는 함수가 호출되고 있다. 이름에서 유추해보자면 필요한 경우 SplashScreen을 옮기는 로직이 실행된다고 볼 수 있다. ExitListener가 설정되어 있거나 다른 조건들에 의해 SplashScreen을 옮겨야 할 경우에 requestCopySplashScreen() 을 호출해주고 있다.

 

ActivityRecord.java

1private boolean transferSplashScreenIfNeeded() {
2    if (!mWmService.mStartingSurfaceController.DEBUG_ENABLE_SHELL_DRAWER) {
3        return false;
4    }
5    if (!mHandleExitSplashScreen || mStartingSurface == null || mStartingWindow == null
6            || mTransferringSplashScreenState == TRANSFER_SPLASH_SCREEN_FINISH) {
7        return false;
8    }
9    if (isTransferringSplashScreen()) {
10        return true;
11    }
12    requestCopySplashScreen();
13    return isTransferringSplashScreen();
14}

 

StartingSurfaceDrawer.java

1public void copySplashScreenView(int taskId) {
2    final StartingWindowRecord preView = mStartingWindowRecords.get(taskId);
3    SplashScreenViewParcelable parcelable;
4    SplashScreenView splashScreenView = preView != null ? preView.mContentView : null;
5    if (splashScreenView != null && splashScreenView.isCopyable()) {
6        parcelable = new SplashScreenViewParcelable(splashScreenView);
7        parcelable.setClientCallback(
8                new RemoteCallback((bundle) -> mSplashScreenExecutor.execute(
9                        () -> onAppSplashScreenViewRemoved(taskId, false))));
10        splashScreenView.onCopied();
11        mAnimatedSplashScreenSurfaceHosts.append(taskId, splashScreenView.getSurfaceHost());
12    } else {
13        parcelable = null;
14    }
15
16    ActivityTaskManager.getInstance().onSplashScreenViewCopyFinished(taskId, parcelable);
17}

 

ActivityRecord.java

1//shell로부터 splash screen data를 얻어와서 client에게 전달함
2//(SplashScreen의 각 설정 값들은 parcelable에 들어 있다.)
3void onCopySplashScreenFinish(SplashScreenViewParcelable parcelable) {
4    ...
5    try {
6        mTransferringSplashScreenState = TRANSFER_SPLASH_SCREEN_ATTACH_TO_CLIENT;
7        mAtmService.getLifecycleManager().scheduleTransaction(app.getThread(), appToken,
8                TransferSplashScreenViewStateItem.obtain(ATTACH_TO, parcelable));
9        scheduleTransferSplashScreenTimeout();
10    } catch (Exception e) {
11        Slog.w(TAG, "onCopySplashScreenComplete fail: " + this);
12        parcelable.clearIfNeeded();
13        mTransferringSplashScreenState = TRANSFER_SPLASH_SCREEN_FINISH;
14    }
15}

 

ActivityThread.java

1private void createSplashScreen(ActivityClientRecord r, DecorView decorView,
2            SplashScreenView.SplashScreenViewParcelable parcelable) {
3        final SplashScreenView.Builder builder = new SplashScreenView.Builder(r.activity);
4        final SplashScreenView view = builder.createFromParcel(parcelable).build();
5        decorView.addView(view);
6        ....
7}

SplashScreen을 copy하여 이후 Activity의 decorView 하위에 붙이는 것으로 보인다. 이후 Activity에서 얻을 수 있는 SplashScreen 및 ExitListener에서 받을 수 있는 SplashScreenView는 이때 copy된 것으로 보인다. 코드에서는 생략된 부분들이 있는데, 아래 sequence diagram에 전체 로직을 그려봤다.

 

SplashScreen Copy 프로세스

 

 

결론

 

SplashScreen은 StartingWindow에서 그려지도록 구현되어 있다. 따라서 StartingWindow를 skip하게 하는 옵션 값인 windowIsTranslucent, windowIsFloating 값을 true로 주어서 SplashScreen이 보이지 않도록 하는 것이 가능하다. 그러나 StartingWindow를 사용하지 않는다는 것은 앱 아이콘을 터치하여 앱이 첫번쨰 프레임을 그리는 사이동안 부드러움을 포기해야 함을 의미한다. 다른 모든앱들이 공통적으로 해당 화면을 사용하고 있다면, 결국 일관된 유저의 앱 사용경험을 위해서라도 기존의 Splash화면을 제거하고 SplashScreen을 사용하는 것이 좋지 않을까 생각한다.

+) windowIsTranslucent, windowIsFloating 값을 true로 설정할때 주의사항이 있다. 아래는 Api level 26의 Activity.java의 코드이다. Api level 26에서 고정된 orientation을 사용하는 Activity에서 windowIsTranslucnet나 windowIsFloating의 값이 true라면 IllegalStateException 이 발생하는 버그가 있다. 따라서 해당 플래그를 사용할때는 주의해야 한다. Api level 27이상부터는 해당 코드는 수정된 것으로 보이나, 제조사별 다른 동작을 할 가능성이 있기 때문에 주의가 필요하다.

 

1protected void onCreate(@Nullable Bundle savedInstanceState) {
2    if (DEBUG_LIFECYCLE) Slog.v(TAG, "onCreate " + this + ": " + savedInstanceState);
3    if (getApplicationInfo().targetSdkVersion > O && mActivityInfo.isFixedOrientation()) {
4        final TypedArray ta = obtainStyledAttributes(com.android.internal.R.styleable.Window);
5        final boolean isTranslucentOrFloating = ActivityInfo.isTranslucentOrFloating(ta);
6        ta.recycle();
7        if (isTranslucentOrFloating) {
8            throw new IllegalStateException(
9                    "Only fullscreen opaque activities can request orientation");
10        }
11    }
12    ...
13}

core/java/android/app/Activity.java - platform/frameworks/base - Git at Google

 

그렇다면 아이콘을 안보이게 해서 빈화면 인 것처럼 보이게는 가능할까?

가장 간단한 방법으로는 앱아이콘에 투명한 Drawable을 넣어서 빈화면이 잠시 떠있는 듯 한 효과를 줄 수 있다. 앱 아이콘에 단순히 @null 을 넣게 되면 기본 런처 아이콘이 들어가므로 android:@color/transparent 와 같은 옵션을 이용하여 colorDrawable을 넣어줘야 한다. 다만 이 경우 contentView가 그려지는 시간이 길어질 수록 빈화면이 떠있을 수 있다.

반응형