02 May 2013

วิธีการทำ Splash Screen ที่ถูกต้อง (แต่ไม่ใช่ที่สุด)

Updated on



        สำหรับบทความนี้ก็ขอพูดถึงเรื่องการทำ Splash Screen กันหน่อย เผื่อผู้ที่หลงเข้ามาอ่านคนไหนยังไม่รู้ งั้นก็เกริ่นหน่อยละกัน Splash Screen ก็คือหน้า Loading ของแอปพลิเคชันนั่นแหละ ส่วนมากจะแสดงโลโก้ค่ายหรือบริษัท หรือโลโก้แอปพลิเคชันนั้นๆ ซึ่งจะพบได้ทั่วไปอยู่แล้วในแอปพลิเคชันทั่วไปโดยเฉพาะเกม

        หลักการของ Splash Screen นั้นก็ไม่ยากอะไรมากมาย ใช้วิธีสร้าง Thread แยกมาตัวหนึ่ง ให้หน่วงเวลาพักนึง แล้วก็ให้ใช้คำสั่ง Intent เพื่อไปยังหน้าหลักนั่นเอง

        สำหรับผู้ที่หลงเข้ามาอ่านมือใหม่ที่ยังไม่รู้เรื่องหน่วงเวลา ว่าทำไมต้องสร้าง Thread แยกด้วย ไม่ใช่ Thread หลักล่ะ? อยากจะให้จำจุดนี้ให้ขึ้นใจว่า Thread หลักบนแอนดรอยด์ จะไม่ยอมให้มีโหลดใดๆก็ตามที่ทำให้ Thread นี้ต้องหยุด เพราะว่า Thread หลักจะเป็นส่วนที่ผู้ใช้ Interactive กับมัน ดังนั้นถ้ามีการหน่วงเวลาจะกลายเป็นว่า Thread หยุด ทำให้แอปพลิเคชันเกิดการค้างไปชั่วขณะหนึ่งเลย ซึ่งภาษาขั้นสูงอย่าง C# หรือ Java ปกติ ก็เป็นแบบนี้เช่นกัน (จริงๆเรื่องมันลึกกว่านี้เยอะ แต่อธิบายคร่าวๆแค่นี้พอ)

        ดังนั้นจึงใช้วิธีสร้าง Thread แยกขึ้นมาหนึ่งตัวแล้วให้ทำการหน่วงเวลาใน Thread นั้นแทน ก็จะทำให้ Thread หลักยังคงทำงานต่อไปได้ เพราะ Thread แยกจะหยุดทำงานเพื่อหน่วงเวลาแทน ดังนั้นคำสั่ง Intent ก็จะต้องอยู่ใน Thread ย่อยนะ เดี๋ยวมีผู้ที่หลงเข้ามาอ่านเข้าใจผิดใส่ที่ Thread หลัก

        เพิ่มเติม - บางคำสั่งไม่สามารถใช้งานใน Thread แยกได้ โดยเฉพาะคำสั่งที่เกี่ยวกับคลาสที่เป็น View ส่วนใหญ่ ไม่ว่าจะ Button, Text View หรือ Image View เป็นต้น ที่บังคับว่าต้องเรียกใช้งานคำสั่งเหล่านั้นใน Thread หลัก โดยสังเกตุได้จาก LogCat ที่จะเกิด Exception ดังนี้

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

        ตัดจบเรื่อง Thread ล่ะ เดี๋ยวจะยาวไปกว่านี้ เข้าเรื่องดีกว่า

        จากหลักการดังกล่าว ก็มีตัวอย่างง่ายๆให้นำไปใช้งานกันและก็พบว่าโค๊ด Splash Screen นั้นๆ มีข้อผิดพลาดอยู่บ้าง โดยข้อผิดพลาดที่ว่าเจ้าของบล็อกขอยกตัวอย่างจากโค๊ด

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.splashscreen);
        
    new Thread(new Runnable() {
        public void run() {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) { }

            Intent intent = new Intent(Splash.this, Main.class);
            startActivity(intent);
        }
    }).start();
}

        สำหรับโค๊ดนี้ดูแล้วคงเข้าใจไม่ยาก คือสร้าง Thread แยก โดยให้ Thread แยกทำการหน่วงเวลา 3 วินาทีแล้ว Intent

        ปัญหาอย่างแรกเลยก็คือการ Intent ไป Main โดยไม่ปิด Activity


        จะเห็นว่าหลังจากที่ขึ้นหน้า Splash Screen เสร็จแล้วก็ไปหน้า Main แต่ว่าพอเจ้าของบล็อกกดปุ่ม Back เพื่อปิดแอปพลิเคชันลง ก็พบว่าเมื่อกดปุ่ม Back แล้วจะกลับไปที่หน้า Splash Screen แทน เพราะไม่ได้ปิด Activity ทิ้งเมื่อตอนที่ Intent ไปหน้า Main ในตอนแรก

        ดังนั้นเพื่อให้สมบูรณ์ หลังจาก Intent แล้วให้ใช้คำสั่ง finish() เพื่อให้ปิด Activity ของ Splash Screen ทิ้งด้วยนั่นเอง

private Handler handler;
private Runnable runnable;

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    requestWindowFeature(Window.FEATURE_NO_TITLE);
    setContentView(R.layout.splashscreen);

    handler = new Handler(); 
        
    runnable = new Runnable() { 
        public void run() { 
            Intent intent = new Intent(Splash.this, Main.class);
            startActivity(intent);
            finish();
        } 
    };
}
    
public void onResume() {
    super.onResume();
    handler.postDelayed(runnable, 3000);
}
    
public void onStop() {
    super.onStop();
    handler.removeCallbacks(runnable);
}

        ทีนี้ปัญหาต่อมาก็คือเวลานักพัฒนาทดสอบแอปพลิเคชันเกือบทั้งหมดทดสอบด้วยการทำงานตามที่ตัวเองคิดไว้ คือเขียนโปรแกรมให้มันทำอะไรก็ทดสอบกดตามนั้น แต่ไม่ได้คำนึงว่าถ้าผู้ใช้กดแล้วจะกดเหมือนกันเสมอไป? ซึ่งจุดนี้นักพัฒนาหลายๆคนได้มองข้ามไปอย่างไม่รู้ตัว


        จากโค๊ดดังกล่าวจะเห็นว่าสามารถทำ Splash Screen ได้เกือบสมบูรณ์แล้ว

                อ่ะ เกือบสมบูรณ์ ? ทำไมล่ะ ? จากคลิปข้างบนนี้น่าจะมีผู้ที่หลงเข้ามาอ่านหลายคนที่ไม่ทันสังเกตุเห็นว่าทำไมมันถึงไม่สมบูรณ์ ให้ดูช่วงท้ายวีดีโอที่กดปุ่ม Home ก่อนที่จะ Intent จากเดิมที่เจ้าของบล็อกบอกไว้ว่า Home คือย่อแอปพลิเคชัน ดังนั้นเมื่อเปิดแอปพลิเคชันขึ้นมาใหม่ก็ไม่ควรจะรอ 3 วินาทีสิสมมติว่าผู้ใช้เปิดขึ้นมาเจอหน้า Splash Screen พอผ่านไป 1 วินาที ผู้ใช้กดปุ่ม Home เพื่อย่อแอปพลิเคชัน ถ้าเปิดขึ้นมาใหม่อีกครั้ง ก็ควรจะรอแค่ 2 วินาที แต่ในวีดีโอ ต่อให้ย่อแอปพลิเคชันแล้วเปิดใหม่ก็รอ 3 วินาทีอยู่ดี
        นี่ล่ะคือจุดที่เจ้าของบล็อกบอกว่าทำไมมันไม่สมบูรณ์
        จะไม่ซีเรียสเรื่องนี้ก็ได้นะ เพราะแค่นี้ก็ถือว่าโอเคแล้ว ปัญหานี้ผู้ที่หลงเข้ามาอ่านหลายๆคนอาจจะมองว่าไม่จำเป็นด้วยซ้ำ ไม่จำเป็นต้องซีเรียสถึงขนาดนั้นเลยนี่ แต่เพื่อเขียนเอามันส์จึงขอเขียนเพิ่มเติมเข้าไปอีก

        เพื่อแก้ปัญหาดังกล่าว เจ้าของบล็อกจึงเพิ่มคำสั่งเข้าไป โดยใช้วิธีเก็บค่าเวลาด้วยว่าตอนที่เกิด onStop ตอนนั้นหน้า Splash Screen ได้ถูกเปิดขึ้นมาแล้วเป็นเวลากี่วินาที เมื่อกลับมาที่แอปพลิเคชันใหม่อีกครั้งก็จะให้นำค่าที่เก็บไว้ไปลบกับ 3 วินาที ผลก็คือเวลาที่ต้องรอจะเหลือน้อยกว่า 3 วินาที ขึ้นอยู่กับว่าตอนแรกเปิดหน้านี้นานเท่าไร

        เพิ่มเติม - สำหรับค่าเวลาที่เก็บไว้จะมีผลกับตอนที่กดปุ่ม Home เท่านั้นแหละ เพราะเมื่อกด Back จะเป็นการปิดแอปพลิเคชัน ทำให้ค่าเวลาที่เก็บไว้ถูกเคลียร์ทิ้งโดยปริยาย แต่สำหรับการกดปุ่ม Home จะทำให้ค่าเวลายังคงเก็บค่าไว้อยู่

private Handler handler;
private Runnable runnable;

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    requestWindowFeature(Window.FEATURE_NO_TITLE);
    setContentView(R.layout.splashscreen);

    handler = new Handler(); 
        
    runnable = new Runnable() { 
        public void run() { 
            Intent intent = new Intent(Splash.this, Main.class);
            startActivity(intent);
            finish();
        } 
    };
}
    
public void onResume() {
    super.onResume();
    handler.postDelayed(runnable, 3000);
}
    
public void onStop() {
    super.onStop();
    handler.removeCallbacks(runnable);
}



        งั้นมาดูโค๊ดตัวอย่างทั้งหมดที่ใช้ในบทความนี้เลยดีกว่า

        เพิ่มเติม - ในตัวอย่างนี้มีเรื่อง Multiple Support Screen เล็กๆน้อยๆที่โฟลเดอร์ drawable สามารถดูการใช้งานเป็นตัวอย่างได้


splashscreen.xml
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#3b92d1" >

    <ImageView
        android:id="@+id/imageView1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_centerHorizontal="true"
        android:src="@drawable/image" />

</RelativeLayout>


Splash.java
package app.akexorcist.splashscreen;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.view.Window;

public class Splash extends Activity {
    Handler handler;
    Runnable runnable;
    long delay_time;
    long time = 3000L;
    
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(R.layout.splashscreen);
        handler = new Handler(); 
        
        runnable = new Runnable() { 
            public void run() { 
                Intent intent = new Intent(Splash.this, Main.class);
                startActivity(intent);
                finish();
            } 
        };
    }
    
    public void onResume() {
        super.onResume();
        delay_time = time;
        handler.postDelayed(runnable, delay_time);
        time = System.currentTimeMillis();
    }
    
    public void onPause() {
        super.onPause();
        handler.removeCallbacks(runnable);
        time = delay_time - (System.currentTimeMillis() - time);
    }
}


main.xml
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#3b92d1" >

    <TextView
        android:id="@+id/textView1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"
        android:text="Main Page"
        android:textColor="#FFFFFF"
        android:textSize="40sp" />

</RelativeLayout>


Main.java
package app.akexorcist.splashscreen;

import android.os.Bundle;
import android.view.Window;
import android.app.Activity;

public class Main extends Activity {

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(R.layout.main);
    }
}


AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="app.akexorcist.splashscreen"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk
        android:minSdkVersion="8"
        android:targetSdkVersion="8" />

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:theme="@style/AppTheme" >
        <activity
            android:name=".Splash"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
 
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity android:name=".Main" />
    </application>

</manifest>


        สำหรับผู้ที่หลงเข้ามาอ่านที่ต้องการไฟล์ตัวอย่างสามารถดาวนโหลดที่ Splash Screen [Google Drive] หรือ Splash Screen [GitHub]

        ในชื่อบทความนี้ที่เจ้าของบล็อกต่อท้ายว่า (แต่ไม่ใช่ที่สุด) ก็เพราะโค๊ดในบทความนี้เป็นโค๊ดที่ถูก แต่ไม่ได้ถูกที่สุด เนื่องจากโค๊ดที่ทำงานได้ถูกต้อง ไม่ได้มีแค่โค๊ดเดียว อยู่ที่ผู้เขียนโปรแกรมจะคิดอัลกอริทึมมาใช้งานเท่านั้น ดังนั้นโค๊ดที่เจ้าของบล็อกใช้ในตัวอย่างนี้จึงไม่ได้ดีที่สุด เพราะอาจจะมีวิธีเขียนแบบอื่นที่ดีกว่านี้ก็เป็นได้นั่นเอง