Android

Splash screen cho Android

Khi tay nghề viết ứng dụng di động của bạn đã chắc thì cách viết ứng dụng của bạn cũng khác so với lúc bạn mới viết được vài dòng đầu tiên. Từ bố cục cho tới các hiệu ứng, bạn sẽ thấy bản thân cần làm gì để sản phẩm của mình nhìn cho đẹp mắt. Và để cho ứng dụng trở nên chuyên nghiệp hơn, splash screen là một thứ rất cần phải có. Vậy splash screen là gì và có những cách nào để làm splash screen trong ứng dụng Android? (Phía iOS thì gọi là launch screen và hướng dẫn của iOS nằm ở bài tiếp theo)

1. What is Splash screen and do we need it?

Thay vì giải thích nhiều lời, bạn hãy nhìn vào hình minh họa bên dưới với ứng dụng YouTube “chính chủ” của Google. Splash screen chính là phần ảnh nền trắng chứa logo YouTube.

youtube_splash

Về mặt lịch sử thì không phải Apple hay Google là người “phát minh” ra cái màn hình này, mà công đầu thuộc về Microsoft với hệ điều hành Windows Phone 7 hay phong cách Metro nói chung. Splash screen đóng vai trò “nhá hàng” cho ứng dụng. Tuy nhiên, cá nhân tôi dùng Splash screen không chỉ đơn thuần với mục đích đó, mà trong quá trình hiện cái logo thì ứng dụng sẽ thực hiện một số tác vụ khác.

Để làm Splash screen thì có nhiều cách khác nhau, từ cách nghiệp dư nhất do những người “chân ướt chân ráo” mới bước vào thế giới của những coder, cho tới cách làm của các developer chuyên nghiệp dày dạn kinh nghiệm. Tuy nhiên, cách nào cũng có những lợi điểm cũng như hạn chế nhất định, không hẳn là “cứ làm theo cách của người chuyên nghiệp đã là ngon”. Dưới đây là hai cách làm phổ biến nhất. Nhưng làm cách nào, thì cái splash screen cũng nằm trong Activity khởi động (tức là MAIN/LAUNCHER).

Ở phần hướng dẫn bên dưới thì tôi quy ước Activity khởi động là SplashActivity, và Activity tiếp theo là MainActivity. Việc chuyển cảnh sang từ SplashActivity sang MainActivity đương nhiên được thực hiện qua hàm startActivity(Intent).

2. The amateur way:

Cách này do các lập trình viên nghiệp dư “phát minh” ra. Cái hay của nó, là việc dễ hiểu và dễ thực hiện. Hướng đi của cách này là tận dùng method postDelayed(Runnable action, long delay) của android.os.Handler. Hàm này có ý nghĩa là sẽ thực hiện action sau đúng một khoảng thời gian delay (tính bằng miliseconds) định trước. Bạn sẽ hiểu kĩ hơn sau phần code bên dưới.

Theo như trong code, thì tên handler sẽ thực hiện hành động action sau đúng một khoảng thời gian delay kể từ khi nó nhận được lệnh run, giống hệt như bạn hôm nay đi mua vé xem phim sẽ chiếu vào tuần tới. Mặc dù bạn nhận được vé vào hôm nay nhưng phải đúng thời điểm ghi trên vé thì phim mới chiếu. Và handler cũng vậy, mặc dù là nó nhận được lệnh lúc bây giờ nhưng phải tới đúng khoảng thời gian sau delay thì mới thực hiện.

Bạn để ý thêm một chút: Ở đây SplashActivity có contentView rõ ràng, được thể hiện ở hàm setContentView(int layout) ngay dưới super.onCreate(Bundle b). Bạn sẽ thiết kế giao diện của Splash screen ở đó. Đây là điểm khác biệt so với cái bên dưới tôi sắp trình bày.

Vậy bạn đang nghĩ là sau khi contentView được dựng xong, bạn thấy cái Activity đã hiện hình rồi thì cái class SplashActivity sẽ tạo instance handler. Tuy nhiên, không phải. Ở đây, sau khi gọi setContentView thì onCreate sẽ gọi tiếp các hàm bên dưới, tức là khởi tạo Handler ngay. Khoảng thời gian render contentView sẽ không được tính vào khoảng delay. Vì vậy, khoảng thời gian chờ thực sự khi người dùng nhìn thấy giao diện của SplashActivity cho tới khi chuyển cảnh bao giờ cũng nhỏ hơn delay.

Từ đây, bạn thấy là phải cân chỉnh con số delay sao cho hợp lí, để giao diện SplashActivity được render xong, phải visible rồi mới chuyển sang MainActivity. Nhưng nói vậy thôi, thực tế thì đối với một Activity không phức tạp cho lắm, thông thường chỉ chứa 1 ImageView để chứa logo và 1 TextView thì 500L đã là quá đủ. Bạn muốn cho người dùng chờ lâu để “chiêm ngưỡng” cái logo của bạn thì tăng delay lên. Tuy nhiên, đối với các máy cấu hình yếu nhưng vẫn chạy Android mới (do dùng ROM CyanogenMod/Lineage) thì có thể xuất hiện contentView gặp lỗi render và chưa render xong sẽ phải chuyển cảnh nên gây leak window content.

Và giờ bạn có nhận ra lí do tại sao mà các professional developers, trong đó theo lời đồn là có cả Mr. Roman Nurik lừng danh (nếu bạn có dùng DashClock thì biết ông ấy) chỉ trích cách làm này chưa? Một là, dùng cách này sẽ phí một handler vô ích – handler được thiết kế để làm nhiệm vụ khác. Thứ hai, là cho dù người dùng vừa thoát ứng dụng xong, bỗng dưng sực nhớ cần làm gì đó mà quay lại ứng dụng thì lại phải chờ đúng khoảng thời gian delay đó mới vào lại ứng dụng. Nên họ đưa ra cách thứ hai bên dưới và gọi đó là “The right way”.

3. The right way:

Cá nhân tôi ghét cay đắng cái cụm “the right way” mà họ dùng, vì nếu bạn cần một background phức tạp, chẳng hạn như cần một ProgressBar thì cách này sẽ trở thành “the worst way” vì không có cách nào bạn nhét cái ProgressBar vào windowBackground của ứng dụng đâu. Mặt khác, tôi cho rằng gọi cách này là “the right way” là có phần cười cợt, khi dễ quá mức những amateur developers. Nó có là “the right way” hay không thì khi người viết code làm, thiết kế Splash screen dư lào thì lúc đó họ mới quyết định là “right” hay “wrong”.

Hướng đi của cách này là sử dụng windowBackground của ứng dụng làm luôn cái nền chứa logo. Phần SplashActivity.java của nó vô cùng đơn giản:

Như vậy, rõ ràng bạn thấy trong onCreate(Bundle b) không có handler, không có delay, cái action nằm trực tiếp trong onCreate luôn. Vậy cái logo nằm ở đâu? Việc cấu hình nó, nghe như dễ dàng nhưng tôi thấy cho dù là mất ít thời gian hơn cách bên trên thì bạn sẽ bị xoắn não hơn rất nhiều. Để thiết kế cái Splash screen này thì lại có hai hướng:

Một là, bạn làm luôn cả cái ảnh PNG (không nên dùng XML – chỉ nên dùng XML trên API 23 về sau nhưng tốt nhất là không dùng) đúng kích thước màn hình, chẳng hạn như cái ảnh chứa logo YouTube bên trên là một cái ảnh PNG có nền trắng, có kích thước hai chiều theo tỉ lệ 9:16 với cái logo YouTube màu đỏ nằm chính giữa. Tuy nhiên, bạn sẽ phải “cực khổ” làm nhiều cái, chí ít là cho các tỉ lệ màn hình khác nhau như 16:9 (lúc xoay ngang trên handsets) hay 4:3, 3:4, 16:10, 10:16 cho các máy tính bảng trong khi bạn bỏ qua các độ phân giải khác nhau như 720*1280 và 2560*1600, giao lại cho hệ thống tự zoom ảnh ra, khác hẳn với “phe” iPhone và iPad vốn cực kì ít phân mảnh.

Hai là, bạn dùng XML, lần này bạn tiếp tục dùng ảnh PNG (nếu được thì nên dùng 9patch PNG) có kích thước trong khoảng 96*96px tới 144*144px (pixels chứ không phải dp hay sp hay mm và nên là hình vuông, có alpha càng tốt). Ở đây, tôi không khuyến khích các bạn làm nhiều kích thước cho các loại màn hình theo dpi (bỏ trong mipmap) mà chỉ làm một cái duy nhất và để trong drawable (và cũng chỉ một folder drawable duy nhất), khi bắt đầu load ảnh thì hệ thống sẽ tự kéo dãn ảnh ra theo hướng 1px của ảnh -> 1dp trên màn hình. Tôi tạm gọi đó là @drawable/app_logo. Tiếp theo, bạn tạo một XML drawable tên là splash_screen.xml có nội dung sau:

Phần @color/backgroundColor là màu nền mà bạn thích, chẳng hạn như cái YouTube bên trên thì nó sẽ là màu trắng (#FFFFFFFF). Phần bitmap với src là app_logo được định nghĩa sẽ bố trí ngay chính giữa màn hình, đúng hơn là chính giữa cái item bên trên nó. Đảm bảo nó luôn nằm chính giữa dù màn hình có kích thước là bao nhiêu đi nữa. Cuối cùng, trong phần styles.xml, bạn định nghĩa một cái theme mới:

Lưu ý là cái parent theme kia phải là NoActionBar. Còn nếu bạn không dùng NoActionBar thì phải tắt ActionBar đi trong mớ item của nó. Và cuối cùng là bạn sẽ buộc cái SplashActivity chạy theme đó trong Manifest.

Lưu ý là chỗ này bạn phải đặt theme “cứng” trong Manifest để khi cài đặt thì Android system sẽ thực thi định nghĩa theme đó ra Dalvik. Nếu bạn không đặt ở đó mà dùng setTheme trong Activity, dù là trước super.onCreate(Bundle) thì sẽ mất thời gian để render và dẫn tới việc hàm startActivity(Intent) được gọi trước khi cái windowBackgroud được dựng xong.

Lợi thế của cách này, điều mà các developers theo “phe” này rất tự hào là việc không phí thời gian đợi của người dùng. Việc render cái windowBackground sẽ nằm trong / là một giai đoạn trong super.onCreate(Bundle) và khi SplashActivity#onCreate(Bundle) chạy hết cái “súp pe” kia thì sẽ chuyển cảnh. Đặc biệt, người dùng vừa thoát ứng dụng xong rồi lập tức quay lại thì quá trình chờ đợi còn ít đi nữa vì phần cache của ứng dụng vẫn còn nằm trên RAM (tức là hot resume). Tuy nhiên, phàm là vậy, nếu cái bạn muốn giao diện của SplashActivity phức tạp hơn, chẳng hạn như cần ProgressBar thì cách nào tự dưng “yếu” đi hẳn. Chưa tính tới việc nếu bạn làm một ứng dụng cần internet, tận dụng cái SplashActivity làm Activity đăng nhập/đăng kí với việc inflate Fragment thì e là chỗ này sẽ trở thành “the worst way”.

4. The official way (Update Sept 23rd, 2017):

Cách này là cách được hướng dẫn trong video chính chủ của đội phát triển Android tại Google, với link đến YouTube ở đây. Bạn vui lòng xem trực tiếp video đó, tuy nhiên nếu bạn lười hoặc không nghe kịp thì tôi sẽ tóm tắt ngắn gọn như sau: Cách này tương tự như cách bên trên, chỉ khác ở chỗ là họ sẽ “nhồi” cái SplashActivity vào luôn MainActivity, tức MainActivity sẽ đóng vai trò “nhá hàng” luôn. Vậy thì làm sao bạn có thể sử dụng AppTheme trong MainActivity? Đơn giản là bạn sẽ gọi setTheme(R.style.AppTheme) trước onCreate như bên dưới. Và vì MainActivity kiêm luôn vai trò của SplashActivity nên bạn không cần chuyển cảnh với startActivity(Intent).

Nghe có vẻ hay đó chứ! Không đâu. Cách này theo tôi là cách dở và có phần “lãng” nhất. Thực tế ngay cả Google cũng không bao giờ sử dụng cách này. Lí do là khi bạn xoay màn hình, Activity sẽ chạy lại từ đầu và cái logo của bạn sẽ xuất hiện trở lại để “ám” người dùng. Một trường hợp nữa là nếu ứng dụng bị diệt do thiếu bộ nhớ, thì khi bạn quay lại ứng dụng, Android system sẽ gọi Activity gần nhất lên lại. Nếu bạn dùng cách này thì Activity sẽ hiện lại cái logo kia một cách không cần thiết. Mặt khác, nếu bạn thay đổi quá nhiều item trong AppTheme so với parent của nó, thì trong những trường hợp vô cùng hiếm, là activity chưa kịp apply các theme items của AppTheme đã phải render contentView, và do đó dẫn tới các View sẽ bị lộn xộn, chưa được “AppCompat hóa” toàn bộ.

Tuy nhiên cũng không phải nó không có điểm tốt. Nếu như ứng dụng của bạn yêu cầu orientation “cứng” và chỉ có mỗi một activity thì cách này tỏ ra vô cùng hiệu quả. Việc ẩn đi cái logo của ứng dụng sẽ diễn ra “nuột” hơn, không phải chuyển sang Activity mới.

5. So sánh và lời kết:

Tóm lại, cách nào cũng có cái hay và cái dở của nó. Tôi tóm tắt lại như sau:

The amateur way: Cách này có các điểm dở là: Phí một handler vô ích, bắt buộc người dùng phải chờ đợi trong khoảng thời gian cứng nhắc gây ức chế. Đặc biệt hơn nếu trong quá trình delay, nếu có lỗi đột xuất nào xảy ra thì ứng dụng sẽ “đứng mãi nơi đó”. Trong một số trường hợp thì contentView gặp lỗi render và chưa render xong sẽ phải chuyển cảnh nên có thể gây leak window content. Tuy nhiên, cái hay của nó cũng phần nào bù trừ các hạn chế đó: Ưu điểm lớn nhất là tận dụng được contentView, để xây dựng các Splashscreen phức tạp, tiện lợi hơn là bạn có thể đặt width và height của ImageView trung tâm trong contentView và dùng src hay srcCompat là một XML vector drawable: Ảnh không bao giờ bị vỡ hay nhòe như “the right way”. Thứ hai là do có contentView nên việc inflate các Fragment là điều không có gì phải bàn cãi. Thứ ba là trong quá trình delay, bạn có thể cho thực hiện một số thao tác mất rất ít thời gian, chẳng hạn như đọc preferences, tạo Thread riêng, v.v…

The right way: /* Nói thêm là tôi chỉ dùng cụm này trong bài này, nên là cụm “the gentle way” thì hợp lí hơn */ Cách này có điểm nổi bật là không phí handler nào cũng như khi chạy super.onCreate(Bundle) xong là chuyển cảnh ngay chứ không buộc người dùng phải đợi. Vì sử dụng windowBackground làm nội dung Splash screen luôn nên không có contentView, từ đó không xuất hiện lỗi trong việc render contentView, và dẫn tới không có hiện tượng “đứng chào cờ” mà không sang MainActivity như bên trên. Tuy nhiên, bất lợi lớn nhất là việc phải cân bằng giứa chất lượng logo hiển thị và công sức bạn bỏ ra, vì bạn nếu bạn tối ưu hóa cho nhiều loại dpi khác nhau, bạn đặt trong mipmap, thì mỗi px trên logo sẽ đúng là một px trên màn hình, do đó bạn lại phải lấy cái file logo gốc ra, export lại ảnh dưới các kích thước lớn hơn ic_launcher trong mipmap (nhỏ nhất cũng phải là 384*384px) và như vậy lại dẫn tới việc tăng kích cỡ tập tin APK theo hướng không đáng có. Một hạn chế khác là không tận dụng được contentView, đối với các Splash screen phức tạp thì lại phải bố trí lại bố cục.

Vì vậy, là người sử dụng cả hai cách rồi, tôi không thấy cách nào tốt hơn hẳn cách kia. Tùy vào project của bạn mà bạn chọn cách nào hợp lí nhất, hoặc thậm chí là “phát minh” ra cách nào hay hơn nữa, chẳng hạn như dùng luôn Fragment trên một Activity! Và khi thấy người khác dùng the amateur way thì cũng đừng chê bai ngay lập tức.

//////////

À quên, nếu bạn đang có thắc mắc là tại sao lại dùng Handler trong the amateur way mà không dùng Thread.sleep hay SystemClock.sleep. Tuy nhiên bạn lại có thể dùng SystemClock.sleep cho “the gentle way”. Lí do là chúng sẽ interupt việc render contentView và contentView chỉ là màu trắng hoặc đen (tùy theo theme ứng dụng) chứ không ra hình dáng mà bạn muốn. Happy coding.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.