打造一個啟動介面

HsuJin發表於2018-07-09

目前,很多 Android 應用都有一個啟動介面 (Launch/Splash Screen),即應用在從桌面或應用抽屜啟動的過程中,顯示的一個有內容的介面,而不是一個空白頁;在應用執行時,啟動介面則不會出現。

根據 Material Design 的定義,啟動介面可分為兩種:

  • 一種是應用在資料載入過程中顯示的不可互動的佔位符介面 (Placeholder UI),它適用於應用啟動以及應用內 Activity 切換的場景,好處是讓使用者感知資料正在載入,而不致於誤認為應用無響應。在此暫不討論這種啟動介面。
  • 另一種是專門在應用啟動時顯示的品牌頁 (Branded Launch Screen),它可以在應用啟動的短暫時間內展示應用的品牌資訊,這有利於提升應用的品牌辨識度。例如以下三款應用的啟動介面都顯示了自家應用的 logo;另外,也可以顯示應用的品牌口號,不過要避免使用需要使用者仔細閱讀的大段文字。

Facebook(左)Twitter(中)Google+(右)

分析啟動介面的概念,其重點應該在於:啟動介面僅在應用啟動的瞬間顯示。也就是說,啟動介面不能導致應用的啟動速度變慢,更不用說強制顯示一段時間了(下面有更多討論)。畢竟沒人是為了看啟動介面而開啟應用的,為使用者提供他們關心的內容才是應用的首要任務。

基於這個原則,Android Development Patterns 專案組的 Ian Lake 在 Google+ 發了一個帖子,詳細敘述了打造啟動介面的“正確”方法。其主體思路是:在應用冷啟動時,也就是使用者點選應用圖示到應用的 Launcher Activity 的 onCreate() 被呼叫的這段時間內,裝置的視窗管理器 (Window Manager) 會根據應用的主題元素 (Theme) 繪製一個佔位符介面,而我們就可以通過設定這個特定的主題元素,使這個佔位符介面替換為有品牌內容的啟動介面。下面分步驟詳細描述。

  1. 設定主題元素

In res/values/styles.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

    <style name=”AppTheme.Launcher”>
        <item name="android:windowBackground">@drawable/launch_screen</item>
        <!-- Optional, on Android 5.0+ you can modify the colorPrimaryDark color to match the windowBackground color for further branding-->
        <!-- <item name="colorPrimaryDark">@android:color/white</item> -->
    </style>
  
</resources>
複製程式碼

由於啟動介面只在應用啟動瞬間顯示,所以不能直接在 AppTheme 中直接修改主題元素,而是需要新建一個繼承 AppTheme 的 Launcher Theme,命名為 AppTheme.Launcher,並且只修改其中的 android:windowBackground 屬性為想要的 drawable 資源即可。對於 Android 5.0 及以上的裝置,還可以修改 colorPrimaryDark 屬性為匹配啟動介面的顏色,提供更好的視覺效果。

  1. 定義啟動介面

In res/drawable/launch_screen.xml

<?xml version="1.0" encoding="utf-8"?>

<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
    android:opacity="opaque">
  
    <!-- The background color, preferably the same as your normal theme -->
    <item android:drawable="@android:color/white"/>
  
    <!-- Your product logo - 144dp color version of your app icon -->
    <item>
        <bitmap
            android:src="@drawable/product_logo_144dp"
            android:gravity="center"/>
    </item>
</layer-list>
複製程式碼

在上面 AppTheme.Launcher 主題中 android:windowBackground 屬性設定的 drawable 資源不能是一個圖片,因為圖片會拉伸至整個螢幕的尺寸;所以這裡需要定義一個 XML 檔案來設定啟動介面。注意 layer-list 的 android:opacity 透明度屬性要設定為 opaque(不透明),以防主題轉換時發生閃屏的情況。

  1. 切換應用主題

設定好 Launcher Activity 在應用啟動瞬間應用的主題後,即可在 AndroidManifest.xml 中設定好,這樣 Launcher Activity 就預設使用 AppTheme.Launcher 主題了,使啟動介面得以顯示。

In AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
    <application ...>
        <activity
            android:name=".MainActivity"
            android:theme="@style/AppTheme.Launcher">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>
複製程式碼

不過,在呼叫 Launcher Activity 的 onCreate() 時,其主題應該切換回 AppTheme,這裡在 super.onCreate() 之前通過 setTheme(R.style.AppTheme) 設定好。

In MainActivity.java

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // Make sure this is before calling super.onCreate
        setTheme(R.style.AppTheme);
        super.onCreate(savedInstanceState);
        // ...
  }
}
複製程式碼

至此,一個啟動介面就打造好了。上述這種實現方法的優勢是不會延長應用的啟動速度,是理論上最好的方案;但是有一個問題,即當 Launcher Activity 重啟時,啟動介面會重新顯示一遍。這個問題的解決方法是新建一個專門的 Splash Activity,使其作為應用的 Launcher Activity;也就是說,應用啟動時,啟動介面顯示完即跳到 Splash Activity 中,隨後再跳到其它 Activity。雖然這樣增加了 Activity 之間切換的延時,不過應用可以實現根據不同的啟動狀態跳轉到不同 Activity 的功能。在此不過多討論。


以上描述的啟動介面的實現方法是符合 Material Design 設計規範的,但是它有很多侷限性,例如無法實現動畫,更不用說國內廠商非常熱衷的定時廣告頁面了。要實現這類啟動介面,就要為應用增加一個介面 (Activity & Layout),相當於在使用者與應用的實際內容之間人為地增加一層關係。這必須權衡利弊,因為即使再酷炫的啟動畫面,使用者也很快會審美疲勞。

下面介紹我在 FilmsPeek 應用中實現的啟動介面,它是一個關於應用 logo 的一秒鐘動畫。此專案託管在我的 GitHub 上,專案介紹已詳細寫在 README 上,歡迎大家 star 和 fork。

打造一個啟動介面

首先為專用於啟動介面的 Splash Activity 構建佈局,基於 Material Design 只顯示應用 logo 或口號的設計規範,在 FilmPeek App 中只擺放了將應用 logo 拆解為“膠片”和“放大鏡”兩部分的兩個 ImageView,以及由於應用了使用 THE MOVIE DB (TMDb) API 而需要顯示其 logo 的 ImageView。為精簡篇幅,程式碼不在此貼出,具體可在 GitHub FilmsPeek Repository 中檢視。值得一提的是,對於這種扁平化的簡單佈局,Android Support 庫提供的 ConstraintLayout 十分簡單易用,這也是 Google Android 團隊大力推廣的。

然後在 Splash Activity 中僅完成一件事即可,那就是使用 AnimationSet 讓應用 logo 的“放大鏡”部分完成一系列的動畫。例如,“放大鏡”首先需要往左直線移動一段距離,這首先可以通過設定一個 TranslateAnimation 物件,然後將該物件新增到 AnimationSet 來實現。

In SplashActivity.java

// Create an animation that make the lens icon move straight left.
Animation straightLeftAnimation = new TranslateAnimation(
        Animation.RELATIVE_TO_SELF, 0,
        Animation.RELATIVE_TO_SELF, -1,
        Animation.RELATIVE_TO_SELF, 0,
        Animation.RELATIVE_TO_SELF, 0);
straightLeftAnimation.setDuration(300);
// Set the LinearInterpolator to the animation to uniform its play velocity.
// The same as below.
straightLeftAnimation.setInterpolator(new LinearInterpolator());
// Add the animation to the set.
animation.addAnimation(straightLeftAnimation);
複製程式碼

由於 Android 未提供做圓周運動的類,所以這裡需要新建一個自定義 Animation 類,在 FilmsPeek App 中實現了順時針的、水平方向的半圓周運動。

In SplashActivity.java

private class SemicircleAnimation extends Animation {

    private final float mFromXValue, mToXValue;
    private float mRadius;

    private SemicircleAnimation(float fromX, float toX) {
        mFromXValue = fromX;
        mToXValue = toX;
    }

    @Override
    public void initialize(int width, int height, int parentWidth, int parentHeight) {
        super.initialize(width, height, parentWidth, parentHeight);

        float fromXDelta = resolveSize(Animation.RELATIVE_TO_SELF, mFromXValue, width, parentWidth);
        float toXDelta = resolveSize(Animation.RELATIVE_TO_SELF, mToXValue, width, parentWidth);

        // Calculate the radius of the semicircle motion.
        // Note: Radius can be negative here.
        mRadius = (toXDelta - fromXDelta) / 2;
    }

    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        float dx = 0;
        float dy = 0;

        if (interpolatedTime == 0) {
            t.getMatrix().setTranslate(dx, dy);
            return;
        }

        float angleDeg = (interpolatedTime * 180f) % 360;
        float angleRad = (float) Math.toRadians(angleDeg);

        dx = (float) (mRadius * (1 - Math.cos(angleRad)));
        dy = (float) (-mRadius * Math.sin(angleRad));

        t.getMatrix().setTranslate(dx, dy);
    }
}
複製程式碼

由於自定義類 SemicircleAnimation 只實現了水平方向的半圓周運動,所以其建構函式的輸入引數只需要水平方向上(X 軸)的起點與終點位置;而且輸入引數都是相對於自身而言的,例如 SemicircleAnimation(0, 2) 表示移動物體從當前位置出發,往右上角做半圓周運動,終點在距離兩倍於自身寬度的水平位置上。

動畫完成後,跳轉至 MainActivity 並呼叫 finish() 使 Splash Activity 不再出現。在 FilmsPeek App 中,通過設定 AnimationSet 的 AnimationListener 來實現。

In SplashActivity.java

animation.setAnimationListener(new Animation.AnimationListener() {
    @Override
    public void onAnimationStart(Animation animation) {
    }

    @Override
    public void onAnimationEnd(Animation animation) {
        // When animation set ended, intent to the MainActivity.
        Intent intent = new Intent(getApplicationContext(), MainActivity.class);
        startActivity(intent);
        // It's IMPORTANT to finish the SplashActivity, so user won't reach it afterwards.
        finish();
    }

    @Override
    public void onAnimationRepeat(Animation animation) {
    }
});
複製程式碼

從上述程式碼可見,自定義 Animation.AnimationListener 需要 override 三個方法,其中在 onAnimationEnd 方法中實現在動畫結束後的操作。

最後在 AndroidManifest 中設定 SplashActivity,使其作為應用的 Launcher Activity;同時也將 Splash Activity 的主題設定為無應用欄的。注意修改 MainActivity 的 intent-filter 屬性。

In AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.android.filmspeek">

    <application ...>
        <activity
            android:name=".SplashActivity"
            android:theme="@style/Theme.AppCompat.Light.NoActionBar">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity
            android:name=".MainActivity"
            android:launchMode="singleTop">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
        </activity>
    </application>
</manifest>
複製程式碼

本文主要觀點來自 Elvis Chidera 在 Medium 發表的文章,在此特表感謝。

相關文章