เรื่องมีอยู่ว่าเพื่อนร่วมทีมเจ้าของบล็อกได้เจอ Issue ตัวหนึ่งบน 8.0 (API 26) ที่ค่อนข้างน่าสนใจมาก จึงเก็บมาเล่าสู่กันฟังครับ เพราะว่านักพัฒนาหลายๆคนน่าจะต้องเจอปัญหานี้เหมือนกัน
ปัญหาอะไรหรือ?
ถ้าผู้ที่หลงเข้ามาอ่านได้ลองทดสอบแอปฯบนเครื่องที่เป็น Android 8.0 Oreo (API 26) แล้วไล่ทดสอบดูทีละหน้าก็อาจจะพบว่ามีบางหน้าพังด้วยสาเหตุว่าjava.lang.IllegalStateException: Only fullscreen opaque activities can request orientation
และถ้าลองไล่เช็คหาสาเหตุดูก็จะพบว่าปัญหานี้เกิดขึ้นจากการที่ Activity มีการกำหนด Style ไว้แบบนี้
<style name="Transparent" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="android:windowIsTranslucent">true</item>
...
</style>
windowIsTranslucent คืออะไร?
เป็น Style ตัวหนึ่งที่เอาไว้กำหนดว่าให้พื้นหลังของ Activity นั้นโปร่งใสได้ เหมาะสำหรับ Activity ในบางหน้าที่อยากจะสร้าง Activity เป็น Overlay ขึ้นมาทับ โดยที่พื้นหลังโปร่งแสงเห็น Activity ตัวเก่าอยู่ด้วยนั่นล่ะครับหน้าที่ของ android:windowIsTranslucent
แล้วทำไมบน Android 8.0 Oreo ถึงมีปัญหากับ Translucent Window ล่ะ?
นั่นสินะ เพราะอะไรหว่า พอลองหาข้อมูลใน Official Documentation ของ Android 8.0 Oreo ก็ไม่ได้อธิบายอะไรในเรื่องนี้ จนต้องไปนั่งเปิดดูโค้ดใน Google Source แล้วเห็นว่าในคลาส Activity นั้นมีการเพิ่มคำสั่งแบบนี้เข้ามาใน Android 8.0 แบบนี้ลองเข้าไปส่องกันได้ที่ Activity.java [Google Source]
จากโค้ดดังกล่าวจะเห็นว่ามีการเช็ค Target SDK Version ว่าถ้ามากกว่า API 26 ขึ้นไป และมีการล็อคไม่ให้หมุนหน้าจอ ก็จะเช็คว่า Activity ตัวนี้ได้กำหนดเป็น Translucent หรือ Floating หรือไม่ ถ้าใช่ก็จะโยน Exception ออกมาทันที
นั่นไง...เจอสาเหตุละ
เมื่อใดที่โปรเจคของผู้ที่หลงเข้ามาอ่านกำหนด Target SDK Version มากกว่า 26 ขึ้นไปและ Activity กำหนดไว้เป็น Translucent หรือ Floating และมีการล็อคหน้าจอไว้ในทิศทางใดทิศทางหนึ่ง จะเกิด IllegalStateException ทันที
ที่มาของปัญหาคืออะไรกันหว่า?
เจ้าของบล็อกเชื่อว่าหลายๆแอปฯนั้นมีการล็อคทิศทางหน้าจอไว้ (ส่วนมากเป็นแนวตั้ง) และบางหน้ามีการทำ Translucent Window ด้วย ดังนั้นจะเกิดปัญหาแอปฯเด้งเพราะกรณีนี้แน่นอนเพื่อแก้ปัญหานี้ก่อนอื่นจะต้องเข้าใจเงื่อนไขของโค้ดชุดนั้นก่อน เพราะเห็นว่ามีการดักด้วยเงื่อนไขดังนี้
// Activity.java (Android 8.0)
if (getApplicationInfo().targetSdkVersion >= O_MR1 && mActivityInfo.isFixedOrientation()) {
...
final boolean isTranslucentOrFloating = ActivityInfo.isTranslucentOrFloating(ta);
...
}
เริ่มจากเข้าไปส่องใน isFixedOrientation() ก่อนละกัน ซึ่งคำสั่งดังกล่าวจะอยู่ในคลาสที่ชื่อว่า ActivityInfo
public boolean isFixedOrientation() {
return isFixedOrientationLandscape()
|| isFixedOrientationPortrait()
|| screenOrientation == SCREEN_ORIENTATION_LOCKED;
}
boolean isFixedOrientationLandscape() {
return isFixedOrientationLandscape(screenOrientation);
}
public static boolean isFixedOrientationLandscape(@ScreenOrientation int orientation) {
return orientation == SCREEN_ORIENTATION_LANDSCAPE
|| orientation == SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|| orientation == SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|| orientation == SCREEN_ORIENTATION_USER_LANDSCAPE;
}
boolean isFixedOrientationPortrait() {
return isFixedOrientationPortrait(screenOrientation);
}
public static boolean isFixedOrientationPortrait(@ScreenOrientation int orientation) {
return orientation == SCREEN_ORIENTATION_PORTRAIT
|| orientation == SCREEN_ORIENTATION_SENSOR_PORTRAIT
|| orientation == SCREEN_ORIENTATION_REVERSE_PORTRAIT
|| orientation == SCREEN_ORIENTATION_USER_PORTRAIT;
}
อืม... จะล็อคไว้ไม่ให้หมุนหน้าจอ, กำหนดเป็นแนวตั้ง หรือกำหนดเป็นแนวนอน จะแบบไหนก็คือว่าเป็น Fixed Orientation ทั้งหมด ซึ่งแอปฯส่วนใหญ่ในบ้านเราก็ชอบล็อคไว้เป็นแนวตั้งอย่างเดียวซะด้วยสิ
ก็เลยไปส่องใน isTranslucentOrFloating(...) ต่อ เผื่อว่าจะเจอเบาะแสอะไรบ้าง
public static boolean isTranslucentOrFloating(TypedArray attributes) {
final boolean isTranslucent = attributes.getBoolean(com.android.internal.R.styleable.Window_windowIsTranslucent, false);
final boolean isSwipeToDismiss = !attributes.hasValue(com.android.internal.R.styleable.Window_windowIsTranslucent) && attributes.getBoolean(com.android.internal.R.styleable.Window_windowSwipeToDismiss, false);
final boolean isFloating = attributes.getBoolean(com.android.internal.R.styleable.Window_windowIsFloating, false);
return isFloating || isTranslucent || isSwipeToDismiss;
}
มีการเช็ค Style Resource ด้วย 3 เงื่อนไขดังนี้
• windowIsTranslucent กำหนดเป็น True
• ไม่ได้กำหนด windowIsTranslucent แต่กำหนด windowSwipeToDismiss เป็น True
• windowIsFloating กำหนดเป็น True
น่าเศร้า... ถูกดักไว้หมดเลย ถ้าเข้าเงื่อนไขใดเงื่อนไขหนึ่งในนี้ก็จะถือว่าเป็น True ทันที ซึ่งกรณีที่ทำ Translucent ก็จะโดน 100% แน่นอน
มาถึงจุดนี้ทำเอาเขวจนไปไม่ถูกเลย ก็เลยต้องย้อนกลับไปดู Commit Message ว่าทำไมถึงเพิ่มคำสั่งแบบนี้เข้ามาใน Android 8.0 กันหว่า ซึ่งได้ใจความประมาณนี้
Prevent non-fullscreen activities from influencing orientation
This changelist enforces that activities targeting O and beyond
can only specify an orientation if they are fullscreen. The
change ignores the orientation on the server side and throws an
exception when the client has an orientation set in onCreate or
invokes Activity#setRequestedOrientation.
ซึ่งคำสั่งนี้ก็ส่งผลไปถึงนักพัฒนาหลายๆคน รวมไปถึงกลุ่มคนที่ใช้ Admob ด้วย เพราะว่าเวลาแสดงผลแบบ Interstitial Ads จะทำให้แอปฯพังเหมือนกัน จน Issue จึงถูกเปิดขึ้นใน Issue Tracker ของ Google ตามนี้ Request: remove new restriction of Android 8.1 : "Only full... [Issue Tracker]
และทีมพัฒนาของ Google ก็ได้ลบคำสั่งนี้ออกไป (ซะงั้น!) ด้วย Commit Message ดังนี้
DO NOT MERGE Remove orientation restriction to only fullscreen activities.
This changelist removes checks that enforce that only fullscreen,
opaque activities may request orientation changes. An application
may itself be compatible with the change and update their SDK level.
However, it is possible they use a library that has not itself been
updated and still leverages this feature for non-fullscreen
activities.
เข้าไปดูข้อมูลของ Commit นี้กันได้ที่ Commit d4ecffa.. [Google Source]
แต่ทว่า Commit นี้มันอยู่ใน Tag android8.1.0-r2 น่ะสิ นั่นหมายความว่ามันถูกแก้แค่หลังจาก Android 8.1 ขึ้นไปเท่านั้น
นั่นหมายความว่า..
ปัญหานี้จะยังเกิดขึ้นอยู่ใน Android 8.0 แต่ไม่มีผลกับ Android 8.1 ขึ้นไป
เนื่องจาก Issue นี้ถูกแก้ไปทีหลังใน Android 8.1 นั่นมันหมายความว่า Issue ตัวนี้จะยังคงอยู่ใน Android 8.0 เหมือนเดิม และนักพัฒนาอย่างเราก็ต้องรับมือกันเองอยู่ดีบ้าจริง!!
รับมือกับปัญหานี้ยังไงดี
อย่างที่บอกไปแล้วว่าทำไมเวลาที่นักพัฒนากำหนด Translucent Window ให้กับ Activity แล้วถึงทำให้แอปฯพังเมื่อเปิดบน Android 8.0ดังนั้นการเลี่ยงปัญหานี้ก็จะมีหลายวิธี โดยขึ้นอยู่กับว่าวิธีไหนจะเหมาะกับผู้ที่หลงเข้ามาอ่านที่สุด (โปรดปรึกษากับลูกค้าหรือ Product Owner ก่อน)
ทำแอปฯให้รองรับการหมุนหน้าจอ
วิธีนี้น่าจะเป็นวิธีที่ดูโอเคที่สุด โดย Follow ตาม Guildeline ของแอนดรอยด์ที่ว่าควรทำแอปฯให้ Responsive กับหน้าจอมากที่สุด อาจจะทำเฉพาะ Activity ที่กำหนด Theme เป็น Translucent อย่างเดียวก็ได้ และควรจัดการเรื่อง Configuration Changes ด้วยนะถ้าลองค้นหาใน StackOverflow ดู จะเห็นบางคนแนะนำให้กำหนดทิศทางของหน้าจอเป็น Unspecified แทน
android:screenOrientation="unspecified"
วิธีนี้เป็นการกำหนดทิศทางหน้าจอตามที่ตั้งค่าไว้ในเครื่องนั้นๆ ซึ่งก็หมายความว่าผู้ที่หลงเข้ามาอ่านก็ต้องทำแอปฯให้รองรับการหมุนหน้าจอได้น่ะแหละ
กำหนด Target SDK Version เป็น 26 หรือต่ำกว่า
เพราะคำสั่งที่มีปัญหาจะทำงานก็ต่อเมื่อกำหนด Target SDK Version ไว้มากกว่า 26 ขึ้นไป ดังนั้นถ้ากำหนดไว้เป็น 26 ต่อไป มันก็จะทำงานได้ปกติแต่วิธีนี้ก็ไม่ค่อยยืนยาวซักเท่าไร เพราะถ้าในอนาคตจำเป็นต้องอัปเดต Target SDK Version เป็นเวอร์ชันที่ใหม่กว่านี้ ก็จะทำให้ปัญหานี้กลับมาใหม่อีกครั้งอยู่ดี
ยกเลิก Translucent Window เฉพาะใน Android 8.0 Oreo
โดยยอมให้พื้นหลังเป็นสีดำแทนที่จะโปร่งแสงเพื่อให้เห็น Activity ตัวก่อนหน้าด้วย แต่วิธีนี้ก็ควรทำเฉพาะ Android 8.0 เท่านั้น ส่วนเวอร์ชันอื่นๆก็ให้ทำเป็น Translucent Window เหมือนเดิมไปนั่นหมายความว่าผู้ที่หลงเข้าาอ่านจะต้องแยก Style Resource สำหรับ API 26 แบบนี้
<!-- values/styles.xml -->
<resources>
...
<style name="TranslucentTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
</style>
</resources>
<!-- values-v26/styles.xml -->
<resources>
<style name="TranslucentTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:windowContentOverlay">@null</item>
</style>
</resources>
<!-- values-v27/styles.xml -->
<resources>
<style name="TranslucentTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
</style>
</resources>
แก้ปัญหาด้วยการ Capture Layout เพื่อไปแสดงผลให้เหมือนกับ Translucent Window
Activity ที่ทำเป็น Translucent Window ให้ทำ Layout สำหรับ API 26 โดยข้างหลังสุดเป็น ImageView ที่ทับด้วย View สีดำโปร่งแสง เวลาที่จะเรียก Activity ตัวนี้ก็ให้ Capture Layout ทั้งหน้าเพื่อส่งมาแสดงเป็นพื้นหลังใน Activity ที่ทำเป็น Translucent Window แต่วิธีนี้ก็ต้องทำควบคุมกับการแยก Style Resource นะ โดยยกเลิก Translucent Window เฉพาะ API 26 เท่านั้นเปลี่ยน Activity ให้กลายเป็น DialogFragment แทน
เพราะ DialogFragment ไม่ใช่ Activity ก็เลยไม่มีปัญหากับคำสั่งนี้ และถ้าใช้ DialogFragment ของ Support v4 ก็หายห่วง เพราะมันสามารถอัปเดตเพื่อแก้ Issue ได้ตลอดเวลา (นี่คือเหตุผลว่าทำไมถึงแนะนำให้ใช้ Component ของ Support Library)
แต่ก็ต้องแลกด้วยการเสียเวลานั่งปรับโค้ดใหม่ให้เหมาะกับ DialogFragment แทน โดยเฉพาะเรื่อง Lifecycle ที่ต่างกันระหว่าง Activity กับ Fragment
ย้ายคำสั่งของ Activity ที่เป็น Translucent Window ไปรวมกับ Activity ก่อนหน้าซะเลย
แลกกับการที่โค้ดรกขึ้นเพราะเอาไปรวมอยู่ในที่เดียวกันโดยเปลี่ยนไปควบคุม Layout เพื่อทำให้ดูเหมือนเป็น Translucent Window แทน แต่วิธีแบบนี้ก็จะไม่เหมาะกับ Activity ที่ถูกเรียกได้จากหลายๆที่สรุป
ถือว่าเป็น Issue ที่ค่อนข้างโหดร้ายสำหรับนักพัฒนาพอสมควร เพราะโค้ดดังกล่าวถูกฝังอยู่ใน Firmware ตั้งแต่แรกเลย ทีมพัฒนาจึงทำได้แค่แก้ไขในเวอร์ชันถัดไปแทน แต่ทว่าผู้ใช้ส่วนใหญ่ก็ยังคงใช้ 8.0 กันอยู่ บางรุ่นบางยี่ห้อเพิ่งจะได้อัปเดตหมาดๆเลย และคงอีกพักใหญ่ที่จะอัปเดตเป็น 8.1ดังนั้นผู้ที่หลงเข้ามาอ่านก็ต้องแบกรับความซวยกันไปฮะ