03 กันยายน 2558

[Android Code] Chrome Custom Tabs ของเล่นใหม่สำหรับ In-app Browser



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

         แต่วันนี้เจ้าของบล็อกจะมาแนะนำสิ่งใหม่ที่ดีกว่านั้นอีก นั่นก็คือ Custom Tabs จาก Chrome นั่นเอง

        Chrome Custom Tabs เป็นลูกเล่นใหม่ของ Chrome ที่พึ่งปล่อยออกมาได้ไม่นาน (สองวันก่อนที่จะเขียนบทความนี้เลยนะเออ) เพื่อให้นักพัฒนาสามารถนำ Chrome ไปแสดงแทน WebView ภายในแอปของตัวเองได้
        อ๊ะๆ ก่อนจะพูดถึงวิธีการใช้งาน ก็ต้องอธิบายก่อนสิว่ามันดียังไง และทำไมต้องเปลี่ยนไปใช้เนอะ

        โดยปกติแล้วจะมีอยู่สองวิธีหลักๆที่ใช้เพื่อเปิด URL ที่ต้องการคือ Intent เพื่อไปเปิดบนแอปตัวอื่นๆกับเรียกใช้ WebView ในแอปแล้วสั่งให้เปิดจากในนั้น ซึ่งทั้งสองวิธีจะมีข้อดีข้อเสียดังนี้

การเปิด URL บนแอปพลิเคชันตัวอื่น

        สมมติว่าผู้ที่หลงเข้ามาอ่านโยนไปที่แอปตัวอื่น ตู้ม ผู้ใช้เลือกเป็น Chrome Browser ขึ้นมา ข้อดีของมันก็คือสามารถใช้ฟีเจอร์ต่างๆของตัว Chrome ได้เต็มที่รวมไปถึง Cookies ด้วย แต่ทว่า UI มันก็เป็นของ Chrome อย่างพวก Menu Option ก็ไปปรับอะไรไม่ได้เลย และตัว Chrome ก็ค่อนข้างกินทรัพยากรสูงเกินไปที่จะใช้เปิด URL เพียงชั่วคราว สรุปสั้นๆก็จะได้แบบนี้
        • กินทรัพยากรเยอะ
        • Custom UI ไม่ได้
        • เข้าถึง Cookies ของผู้ใช้ได้
        • ใช้งานฟีเจอร์ของ Chrome ได้เต็มที่

การเปิด URL ด้วย WebView จากภายในแอปพลิเคชัน

        วิธีนี้ค่อนข้างยอดนิยมมากกว่าวิธีแรก เพราะบ่อยครั้ง Requirement ของงานคือต้องให้มันเปิดอยู่ภายในแอปพลิเคชันให้ได้ ดังนั้นเลิกพูดถึงการใช้โยน URL เพื่อออกไปเปิดข้างนอกได้เลย การสร้างซักหน้าขึ้นมาแล้วลาก WebView จึงเหมาะสมที่สุด อีกทั้งตัวมันยังใช้ทรัพยากรน้อยกว่า Chrome และสามารถจัดการกับ UI ได้ แต่ทว่าก็ยังมีข้อเสียในเรื่องการจัดการกับ Cookies ที่ยุ่งยากและใช้งานฟีเจอร์ของ Chrome ไม่ได้ (อย่างพวกจำรหัสผ่าน เป็นต้น) สรุปสั้นๆคือ
        • กินทรัพยากรน้อย
        • Custom UI ได้
        • เข้าถึง Cookies ของผู้ใช้ไม่ได้
        • ใช้งานฟีเจอร์ของ Chrome ไม่ได้

"เราเกิดมาเพื่อสิ่งนี้" Chrome Custom Tabs ไม่ได้กล่าวไว้

        จากความต้องการดังกล่าวจึงทำให้ทีมพัฒนา Chrome Browser for Android ได้ใส่ฟีเจอร์ Custom Tabs ไว้ให้ เพื่อให้นักพัฒนาสามารถเรียกใช้งานฟีเจอร์นี้ผ่านแอปของนักพัฒนาเองได้ โดยที่ Custom Tabs จะถูกออกแบบมาให้ใช้ทรัพยากรน้อยแบบ WebView สามารถปรับ UI ได้พอสมควร แต่ยังคงสามารถเข้าถึง Cookies ของผู้ใช้และฟีเจอร์ต่างๆของ Chrome ได้นั่นเอง
        • กินทรัพยากรน้อย
        • Custom UI ได้พอประมาณ
        • เข้าถึง Cookies ของผู้ใช้ได้
        • ใช้งานฟีเจอร์ของ Chrome ได้

        และจุดเด่นที่น่าสนใจที่สุดก็คือการทำ Prefetching ที่จะดาวน์โหลดข้อมูลจาก URL มาเตรียมไว้ล่วงหน้าก่อน แต่ยังไม่แสดง เมื่ออยากแสดงก็จะแสดงได้ทันทีโดยไม่ต้องรอโหลดจนเสร็จ

        นึกไม่ออกมาว่าดียังไงก็ลองดูภาพข้างล่างประกอบเลย เป็นการเปรียบเทียบระหว่าง Chrome Custom Tabs ที่ทำ Prefetch ไว้แล้ว กับ Chrome และ WebView แบบเดิมๆ



        อ่ะ น่าสนใจแล้วใช่มั้ยล่ะ งั้นมาดูวิธีการเรียกใช้งานดีกว่า

        สำหรับ Chrome Custom Tabs นั้นมีเงื่อนไขในการใช้งานอยู่ว่า เครื่องที่ใช้งานได้จะต้องติดตั้ง Chrome Browser เวอร์ชัน 45 ขึ้นไป (พึ่งปล่อยให้ใช้งานเมื่อวันที่ 1 กันยานี้เอง) และแอปที่จะเรียกใช้งานต้องกำหนด SDK ขั้นต่ำเป็น API 15 (Android 4.0.4 Icecream Sandwich)

เพิ่ม Dependency ลงใน Gradle

        Custom Tabs มาในรูปแบบของ Android Support Library จึงสามารถเพิ่มเป็น Dependency ลงใน build.gradle ได้เลย

dependencies {
    ...
    compile 'com.android.support:customtabs:23.0.0'
}

เชื่อมต่อกับ Custom Tabs Service

        ในการใช้งาน Custom Tabs จะต้องติดต่อใช้งานจาก Chrome ผ่าน Services เสียก่อน ซึ่งจริงๆแล้วมันก็คือการส่ัง Run Service ของ Custom Tabs นั่นแหละ (เพราะอะไรถึงต้องเป็น Service เดี๋ยวบอกทีหลัง) โดยการเชื่อมต่อกับ Custom Tab Service จะมีโค๊ดดังนี้

private CustomTabsConnection customTabsConnection;

public void connectCustomTabsService() {
    String chromePackageName = "com.android.chrome";
    customTabsConnection = new CustomTabsServiceConnection() {
        @Override
        public void onServiceDisconnected(ComponentName name) {
            // หยุดเชื่อมต่อ Custom Tabs Service
        }

        @Override
        public void onCustomTabsServiceConnected(ComponentName componentName, CustomTabsClient customTabsClient) {
            // เชื่อมต่อกับ Custom Tabs Service ได้แล้ว
        }
    };
    CustomTabsClient.bindCustomTabsService(this, chromePackageName, customTabsConnection);
}

        เวลาเรียกใช้งานก็ควรเรียกแค่ครั้งเดียวตอนที่เปิด Activity ที่จะเรียก Custom Tabs และเมื่อ Activity ปิดลงก็อย่าลืมปิด Service ของ Custom Tabs ด้วย

@Override
protected void onDestroy() {
    super.onDestroy();
    if (customTabsConnection != null)
        unbindService(customTabsConnection);
}

        จะเห็นว่าเมื่อเชื่อมต่อได้ ก็จะได้ CustomTabsClient มาด้วย ซึ่งตัวนี้จะนำไปใช้ตอนเปิด Custom Tabs อีกทีหนึ่ง โดยทำการสร้าง Session ขึ้นมาซะ ซึ่งในขั้นตอนนี้จะมีการสร้าง Callback ของ Navigation Event ขึ้นมาด้วย (เดี๋ยวค่อยอธิบายต่ออีกที)

private CustomTabsSession customTabsSession;

public void createCustomTabsSession(CustomTabsClient customTabsClient) {
    customTabsSession = customTabsClient.newSession(new CustomTabsCallback() {
        @Override
        public void onNavigationEvent(int navigationEvent, Bundle extras) {
            // เมื่อมี Navigation Event ใดๆเกิดขึ้นบน Custom Tabs
        }
    });
}

        ซึ่ง createCustomTabsSession นี้จะเอาไปใช้ใน  onCustomTabsServiceConnected โดยให้ส่ง CustomTabsClient มา เพื่อสร้าง Session นั่นเอง

        เสร็จแล้วสำหรับการเตรียมพร้อมเพื่อใช้งาน Custom Tabs ซึ่งสรุปโค๊ดที่อธิบายมาทั้งหมดได้ดังนี้

private CustomTabsConnection customTabsConnection;
private CustomTabsSession customTabsSession;

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...

    connectCustomTabsService();
}

public void connectCustomTabsService() {
    String chromePackageName = "com.android.chrome";
    customTabsConnection = new CustomTabsServiceConnection() {
        @Override
        public void onServiceDisconnected(ComponentName name) {
            // หยุดเชื่อมต่อ Custom Tabs Service
        }

        @Override
        public void onCustomTabsServiceConnected(ComponentName componentName, CustomTabsClient customTabsClient) {
            // เชื่อมต่อกับ Custom Tabs Service ได้แล้ว
            createCustomTabsSession(customTabsClient);
        }
    };
    CustomTabsClient.bindCustomTabsService(this, chromePackageName, customTabsConnection);
}

public void createCustomTabsSession(CustomTabsClient customTabsClient) {
    customTabsSession = customTabsClient.newSession(new CustomTabsCallback() {
        @Override
        public void onNavigationEvent(int navigationEvent, Bundle extras) {
            // เมื่อมี Navigation Event ใดๆเกิดขึ้นบน Custom Tabs
        }
    });
}

@Override
protected void onDestroy() {
    super.onDestroy();
    if (customTabsConnection != null)
        unbindService(customTabsConnection);
}

        ต่อไปเป็นการสั่งงานให้ Custom Tabs เปิด URL ที่ต้องการแล้ว โดยจะมีโค๊ดอย่างง่ายแบบนี้

private Uri uri = Uri.parse("http://www.akexorcist.com");
public void openCustomTabs() {
    CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(session);
    CustomTabsIntent customTabsIntent = builder.build();
    customTabsIntent.launchUrl(this, uri);
}

        ขั้นตอนนี้ไม่ยากมากนัก ซึ่งจริงๆแล้วก็เป็นแค่การสร้าง Intent สำหรับ Custom Tabs ขึ้นมานั่นเอง แต่ว่าจะเรียกผ่านคำสั่งจากคลาส CustomTabsIntent แทน

        ทวนสิ่งที่ต้องทำอีกครั้งนะครับ ตอนที่เปิด Activity ขึ้นมาจะต้องเรียกคำสั่ง connectCustomTabsService ด้วย เพื่อให้เตรียม Custom Tabs ไว้ จากนั้นเมื่อต้องการให้เปิดขึ้นมาก็ใช้คำสั่ง openCustomTabs ซึ่งเจ้าของบล็อกจะสร้างปุ่มโง่ๆขึ้นมาหนึ่งตัวเพื่อกดเปิด

public class MainActivity extends AppCompatActivity implements View.OnClickListener {
    private CustomTabsConnection customTabsConnection;
    private CustomTabsSession customTabsSession;
    private Uri uri = Uri.parse("http://www.akexorcist.com");

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...

        findViewById(R.id.btn_click).setOnClickListener(this);

        connectCustomTabsService();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (customTabsConnection != null)
        unbindService(customTabsConnection);
    }

    @Override
    public void onClick(View v) {
        int id = v.getId();
        if (id == R.id.btn_click) {
            openCustomTabs();
        }
    }

    public void connectCustomTabsService() {
        String chromePackageName = "com.android.chrome";
        customTabsConnection = new CustomTabsServiceConnection() {
            @Override
            public void onServiceDisconnected(ComponentName name) {
                // หยุดเชื่อมต่อ Custom Tabs Service
            }

            @Override
            public void onCustomTabsServiceConnected(ComponentName componentName, CustomTabsClient customTabsClient) {
                // เชื่อมต่อกับ Custom Tabs Service ได้แล้ว
                createCustomTabsSession(customTabsClient);
            }
        };
        CustomTabsClient.bindCustomTabsService(this, chromePackageName, customTabsConnection);
    }

    public void createCustomTabsSession(CustomTabsClient customTabsClient) {
        customTabsSession = customTabsClient.newSession(new CustomTabsCallback() {
            @Override
            public void onNavigationEvent(int navigationEvent, Bundle extras) {
                // เมื่อมี Navigation Event ใดๆเกิดขึ้นบน Custom Tabs
            }
        });
    }

    public void openCustomTabs() {
        CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(session);
        CustomTabsIntent customTabsIntent = builder.build();
        customTabsIntent.launchUrl(this, uri);
    }
}

        พอลองทดสอบแล้วกดปุ่มดูก็จะเปิด Chrome Custom Tabs ขึ้นมาพร้อมกับแสดงหน้า URL ที่กำหนดไว้ทันที



        ขอเพิ่มเติมตรง onNavigationEvent เสียหน่อย เพราะในนี้จะมีการส่งตัวแปร navigationEvent มาให้ด้วย ซึ่งค่าที่ว่านี้จะอิงค่าจากคลาส CustomTabsCallback ดังนี้

CustomTabsCallback.NAVIGATION_ABORTED
CustomTabsCallback.NAVIGATION_FAILED
CustomTabsCallback.NAVIGATION_FINISHED
CustomTabsCallback.NAVIGATION_STARTED
CustomTabsCallback.TAB_HIDDEN
CustomTabsCallback.TAB_SHOWN

        แต่ทว่าตัวอย่างนี้เจ้าของบล็อกยังไม่ได้ทำ Prefetch จึงไม่ต่างอะไรกับการเปิด URL แบบปกติ ดังนั้นลองมาใส่คำสั่งสำหรับทำ Prefetch กันดีกว่า

ลองทำ Prefetch

        ในการเขียน Prefetch จะต้องมาเขียนใน createCustomTabsSession ที่ได้เตรียมไว้ครับ และจะต้องเขียนเยอะมากๆ เพราะมีขั้นตอนซับซ้อนวุ่นวายมาก เลยทำให้โค๊ดใน createCustomTabsSession ต้องเขียนเพิ่มอีกยาวเหยียดแบบนี้

public void createCustomTabsSession(CustomTabsClient customTabsClient) {
    customTabsClient.warmup(0);
    customTabsSession = customTabsClient.newSession(new CustomTabsCallback() {
        @Override
        public void onNavigationEvent(int navigationEvent, Bundle extras) {
            // เมื่อมี Navigation Event ใดๆเกิดขึ้นบน Custom Tabs
        }
    });
    session.mayLaunchUrl(uri, null, null);
}

        ยาวมาก!!! เพิ่มมาตั้งสองบรรทัดแน่ะ!!! #ผิด

        สำหรับสองบรรทัดนี้จะเป็นการทำให้ Custom Tabs สามารถทำ Prefetch ได้ โดยที่

        • warmup คือการสั่งให้ Custom Tabs เตรียมพร้อมที่จะคำงาน (Initialize)
        • mayLaunchUrl คือการบอกกับ CustomTabs ว่า URL ตัวนี้อาจจะถูกเปิดนะ ให้โหลดข้อมูลมาเตรียมรอไว้เลย

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

ลอง Custom UI เล่นนิดหน่อย

        ถ้าสังเกตดีๆใน openCustomTabs ตรง Builder ของ CustomTabsIntent จะก็จะพบว่ามันสามารถ Custom UI บางส่วนได้ (ก็ที่เป็น Builder ก็เพื่อการนี้นี่แหละ) ดังนั้นผู้ที่หลงเข้ามาอ่านสามารถกำหนดค่าต่างๆเพิ่มเติมเข้าไปหน้า CustomTabs ได้


        ซึ่งผู้ที่หลงเข้ามาอ่านสามารถกำหนดการแสดง Title ของหน้า CustomTabs หรือจะเปลี่ยนสีก็ทำได้ หรือแม้แต่การเปลี่ยนปุ่มปิด CustomTabs และกำหนดเมนูที่จะแสดงในนั้นว่าเมื่อกดแล้วจะให้ Intent ไปที่หน้าไหน หรือกำหนดรูปแบบของ Animation เวลาเปิด/ปิดหน้า CustomTabs ก็ทำได้  (แจ่มจริงๆ)



         setShowTitle ที่แสดงชื่อของเว็ปนั้นๆ



        setToolbarColor กำหนดสีของแถบ Toolbar แถมสีข้อความกับปุ่มมีการเปลี่ยนให้จัดกับสีที่กำหนดด้วย



        setCloseButtonIcon กำหนดภาพของปุ่มปิด CustomTabs โดยกำหนดเป็น Bitmap ที่ต้องการ



        setStartAnimation และ setEndAnimation กำหนด Animation ตอนเปิดหน้า CustomTabs (มันก็คือการใช้คำสั่ง overridePendingTransition นั่นเอง) โดยกำหนดเป็น Animation Resource ID

        enableUrlBarHiding สำหรับซ่อน URL ของหน้าเว็ปที่เปิดอยู่ แต่ดูเหมือนว่าตอนนี้จะยังใช้งานไม่ได้

        setActionButton เป็นการเพิ่มปุ่ม Action เข้าไปข้างๆปุ่มเมนู โดยจะกำหนด Pending Intent เมื่อกดที่ปุ่มนั้นๆได้ จึงสามารถนำไปกำหนดได้ว่าเมื่อกดปุ่ม Action แล้วจะให้เปิดอะไรขึ้นมาได้ (แต่ไม่สามารถเข้าไปสั่งงานข้างในเว็ปโดยตรงได้)

        ตัวอย่างก็ประมาณนี้ อันนี้สมมติว่ากดแล้วจะให้ส่งเมลล์โดยจะขึ้นรายชื่อแอปให้เลือกอีกที

Uri uri = .......
CustomTabsSession session = .......

CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(session);

Intent actionIntent = new Intent(Intent.ACTION_SEND);
actionIntent.setType("*/*");
actionIntent.putExtra(Intent.EXTRA_EMAIL, "example@example.com");
actionIntent.putExtra(Intent.EXTRA_SUBJECT, "example");
PendingIntent pi = PendingIntent.getActivity(getApplicationContext(), 1, actionIntent, 0);
Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.config);
builder.setActionButton(icon, "Send Email", pi, true);

CustomTabsIntent customTabsIntent = builder.build();
customTabsIntent.launchUrl(this, uri);

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

        ดังนั้นภาพ Config ที่เจ้าของบล็อกเอามาเป็นสีขาว เมื่อพื้นหลังเป็นสีขาวมันก็จะปรับเป็นสีอื่นให้อัตโนมัติเลย



        addMenuItem เป็นอีกหนึ่งอย่างที่เจ้าของบล็อกชอบ เพราะมันจะทำให้ผู้ที่หลงเข้ามาอ่านสามารถเพิ่มเมนูเข้าไปในปุ่มเมนูได้ ส่วนการกำหนดก็เหมือนกับ Action Button นั่นแหละ กำหนดเป็น PendingIntent

Uri uri = .......
CustomTabsSession session = .......

CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(session);

Intent menuIntent = new Intent();
menuIntent.setClass(getApplicationContext(), DestinationActivity.class);
Bundle menuBundle = ActivityOptions.makeCustomAnimation(this, android.R.anim.slide_in_left, android.R.anim.slide_out_right).toBundle();
PendingIntent pi = PendingIntent.getActivity(getApplicationContext(), 0, menuIntent, 0, menuBundle);
builder.addMenuItem("Menu 1", pi);

CustomTabsIntent customTabsIntent = builder.build();
customTabsIntent.launchUrl(this, uri);

        จากตัวอย่างข้างบน เจ้าของบล็อกสมมติว่ากดปุ่ม Menu 1 แล้วจะเป็นการเปิดหน้า DestinationActivity ขึ้นมา และจะเห็นว่าสามารถกำหนด Animation ระหว่างเปิดหน้าดังกล่าวได้ด้วย


        ซึ่งเมนูดังกล่าวสามารถเพิ่มกี่ตัวก็ได้ตามใจชอบจ้า (ใช้คำสั่ง addMenuItem เข้าไปเรื่อยๆ)

สรุป

       Chrome Custom Tabs เป็นอีกหนึ่ง Library ใหม่จาก Android Support Library ที่จะช่วยให้นักพัฒนาสามารถจัดการกับการแสดงผลแบบ Website ได้สะดวกขึ้น หมดจากปัญจากการ Intent แอปอื่นๆหรือการใช้ WebView โดยใช้ความสามารถจาก Chrome Browser ที่มีอยู่แทบทุกเครื่องบนอุปกรณ์แอนดรอยด์ในปัจจุบันนี้ เพราะ CustomTabs จะเป็นการจับข้อดีระหว่างทั้งสองมารวมกัน แต่ถึงกระนั้นก็อาจจะไม่เหมาะกับงานที่ต้องการ Custom Layout ในหน้านี้ได้ ทำได้แค่กำหนดพวก Toolbar นิดหน่อยเท่านั้น

        สำหรับสิ่งที่ควรระวังในการใช้ Chrome Custom Tabs คือมันจะรองรับ API 16 หรือ Android 4.1 Jelly Bean ขึ้นไป และต้องใช้ Chrome เวอร์ชัน 45 ขึ้นไป และในขั้นตอนการกำหนด Package ของ Chrome จะต้องเขียนป้องกันกรณีที่ผู้ใช้ไม่มี Chrome ภายในเครื่องด้วย (แต่ Android 4.1 ขึ้นไปทุกเครื่องจะมี Chrome ติดตั้งมาด้วยอยู่แล้ว)

        และขอทิ้งท้ายด้วยวีดีโอ 100 Days of Google Developers  ตอนที่ 94 ที่พูดถึงเรื่อง Chrome Custom Tabs ให้ผู้ที่หลงเข้ามาอ่านได้ไปนั่งดูกันนะครับ



ข้อมูลเพิ่มเติม

        • Chrome custom tabs smooth the transition between apps and the web
        • Custom Tabs Support Library [Android Developer]
        • Chrome Custom Tabs [Chrome Developer]
        • Custom Tabs Client [GitHub]




เหล่าพันธมิตรแอนดรอยด์

Devahoy Layer Net NuuNeoI The Cheese Factory Somkiat CC Mart Routine Artit-K Arnondora Kamonway Try to be android developer Oatrice Benz Nest Studios Kotchaphan@Medium Jirawatee@Medium Travispea@Medium