วิธีการทำให้ onActivityResult ของ Nested Fragment ใช้งานได้ในทุกกรณี

Posted on 13 Mar 2015 20:48 | 21185 reads | 0 shares
 

หนึ่งในปัญหาสามัญที่เจอบน Fragment คือ Nested Fragment ที่ถึงจะสามารถสั่ง startActivityForResult ได้ แต่กลับไม่สามารถรับ onActivityResult ได้ สร้างความปวดหัวมากมายให้ทั้งโปรแกรมเมอร์มือใหม่และมือเก่า

ทั้งนี้เพราะว่า Fragment ในตอนแรกไม่ได้ถูกออกแบบมาให้ทำ Nested Fragment พอระบบขยายให้ทำ Fragment ซ้อน Fragment ได้ ระบบที่ออกแบบไว้ก็เลยไม่ครอบคลุมตรงนี้

แต่ไม่ต้องเป็นห่วง เพราะเรามีทางแก้แบบยั่งยืนแล้ว ตามที่จะเขียนให้อ่านให้ชมกันในบทความนี้ครับ

เบื้องหลังการทำงานของ startActivityForResult บน Fragment

ถึงแม้เราจะสามารถเรียก startActivityForResult บน Fragment ได้โดยตรงก็จริง แต่ในระบบเบื้องหลัง คนที่จัดการทุกอย่างก็ยังคงเป็น Activity อยู่ดี ดังนั้นหากเรียก startActivityForResult จาก Fragment ระบบจะทำการเปลี่ยน requestCode เพื่อแนบรหัสว่า Fragment ไหนเป็นคนส่งคำสั่งนี้มา

จนกระทั่งทุกอย่างเสร็จสิ้น ตอน Navigate กลับมา Activity ก็จะได้รับผลการทำงานผ่านทาง onActivityResult พร้อมกับพิจารณาจากรหัสที่แนบมากับ requestCode ว่ามันเป็นของ Fragment ไหนและจะทำการแปลง requestCode กลับสู่เลขเดิมก่อนจะส่งไปให้ Fragment นั้นๆผ่าน onActivityResult เป็นอันจบลูป

อย่างไรก็ตาม การส่งผลการทำงานไปให้ Fragment ที่ onActivityResult จะทำได้แค่ระดับเดียวเท่านั้น ก็คือไปหา Fragment ที่อยู่ติดกับ Activity แต่พอเป็น Fragment ที่ซ้อน Fragment อีกที ก็อยู่เหนือการควบคุมของ Activity แล้ว เป็นเหตุให้พอเป็น Nested Fragment แล้ว คำสั่ง onActivityResult จะไม่มีทางถูกเรียกเลยนั่นเอง

แก้ไขปัญหาด้วย EventBus

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

ปัญหาที่เกิดขึ้นกรณี Nested Fragment คือส่งไปได้เสมอ แต่ตอนรับกลับกลับไม่ได้เพราะระบุตัวคนรับไม่ได้ ส่งสัญญาณไปไม่ถึง ก็ถ้าเป็นอย่างนั้นแล้ว ก็ไม่มีประโยชน์อะไรแล้วที่จะยิง startActivityForResult จาก Fragment ... ก็โยนหน้าที่การจัดการทั้งหมดไปที่ Activity เลยสิ!

แนวคิดเปลี่ยน โค้ดก็เปลี่ยนตาม ใน Fragment แทนที่จะเรียก startActivityResult(...) เฉยๆ ก็เปลี่ยนไปเรียก getActivity().startActivityForResult(...) แทน ดังนี้

// In Fragment
Intent intent = new Intent(getActivity(), SecondActivity.class);
getActivity().startActivityForResult(intent, 12345);

จากนี้ข้อมูลตอบกลับทั้งหมดจะไปกองอยู่ที่เดียวคือ onActivityResult ของ Activity ที่ถือครอง Fragment นั้นๆอยู่

คำถามต่อไปคือจะส่งไปบอก Fragment ยังไงว่ามีผลการทำงานตอบกลับมาแล้วนะ?

เนื่องจากว่าเราไม่สามารถวิ่งไปหา Nested Fragment ตรงๆได้ แต่ในขณะเดียวกัน Fragment แต่ละตัวก็รู้อยู่แล้วว่าใครเป็นคนส่ง startActivityForResult ไป โดยแยกจาก requestCode นั่นเอง ด้วยแนวคิดนี้จึงเกิดเป็นไอเดียขึ้นมาว่า "แล้วทำไมไม่ Broadcast บอกทุกตัวไปเลยหละ แล้วให้เช็ค requestCode กันเอาเอง"

จะใช้ LocalBroadcastManager ก็เป็นวิธีที่เก่าไปและโค้ดไม่สวย พระเอกของเราในที่นี้จึงคือของที่เกิดมาทนแทนกันอย่าง EventBus ซึ่งมีให้เลือกใช้อยู่หลายตัว ในที่นี้เราขอเลือกใช้ Otto ของ Square ในการทำหน้าที่

เพิ่ม dependency เข้า build.gradle ก่อนเลย

dependencies {
  compile 'com.squareup:otto:1.3.6'
}

แล้วก็สร้าง Bus Event ขึ้นมาหนึ่งตัวเพื่อใช้ส่งสัญญาณหาผู้รับทุกคนใน Bus

ActivityResultEvent.java

import android.content.Intent;

/**
 * Created by nuuneoi on 3/12/2015.
 */
public class ActivityResultEvent {

    private int requestCode;
    private int resultCode;
    private Intent data;

    public ActivityResultEvent(int requestCode, int resultCode, Intent data) {
        this.requestCode = requestCode;
        this.resultCode = resultCode;
        this.data = data;
    }

    public int getRequestCode() {
        return requestCode;
    }

    public void setRequestCode(int requestCode) {
        this.requestCode = requestCode;
    }

    public int getResultCode() {
        return resultCode;
    }

    public void setResultCode(int resultCode) {
        this.resultCode = resultCode;
    }

    public Intent getData() {
        return data;
    }

    public void setData(Intent data) {
        this.data = data;
    }
}

และแน่นอน สร้าง Singleton ของ Bus ขึ้นมาเพื่อใช้รับ-ส่งข้อมูลระหว่าง Activity และ Fragment จากนี้จะได้ใช้ตัวกลางตัวเดียวในการคุยกันได้อย่างง่ายดาย

ActivityResultBus.java

import android.os.Handler;
import android.os.Looper;

import com.squareup.otto.Bus;

/**
 * Created by nuuneoi on 3/12/2015.
 */
public class ActivityResultBus extends Bus {

    private static ActivityResultBus instance;

    public static ActivityResultBus getInstance() {
        if (instance == null)
            instance = new ActivityResultBus();
        return instance;
    }

    private Handler mHandler = new Handler(Looper.getMainLooper());

    public void postQueue(final Object obj) {
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                ActivityResultBus.getInstance().post(obj);
            }
        });
    }

}

ทั้งนี้จากโค้ดด้านบน เราเพิ่มคำสั่ง postQueue ขึ้นมาเพราะในการส่งข้อมูลจาก Activity ไปหา Fragment เราจะต้องทำการ Delay การส่งไปเล็กน้อยเพราะ Fragment ยังไม่ทันถูกนำขึ้นมาแสดงตอนที่ onActivityResult ของ Activity ถูกเรียก ตรงนี้ปล่อยให้ Handler ทำหน้าที่ต่อท้ายคิวของ Main Thread ด้วยคำสั่ง post(...) ตามโค้ดด้านบน

ใน Activity ให้ Override คำสั่ง onActivityResult แล้วใส่คำสั่งเพิ่มเข้าไปหนึ่งบรรทัดเพื่อส่งสัญญาณเข้า Bus ดังนี้

public class MainActivity extends ActionBarActivity {

    ...

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        ActivityResultBus.getInstance().postQueue(
                    new ActivityResultEvent(requestCode, resultCode, data));
    }

    ...

}

ต่อไปก็ทำการดักจับสัญญาณเหล่านี้จากทาง Fragment ด้วยการ Register Fragment เข้ากับ Bus พร้อมสร้างคลาสเพื่อรับสัญญาณ และอย่าลืม Unregister ตอน Fragment ไม่ได้อยู่บนหน้าจอด้วย

public class BodyFragment extends Fragment {

    ...

    @Override
    public void onStart() {
        super.onStart();
        ActivityResultBus.getInstance().register(mActivityResultSubscriber);
    }

    @Override
    public void onStop() {
        super.onStop();
        ActivityResultBus.getInstance().unregister(mActivityResultSubscriber);
    }

    private Object mActivityResultSubscriber = new Object() {
        @Subscribe
        public void onActivityResultReceived(ActivityResultEvent event) {
            int requestCode = event.getRequestCode();
            int resultCode = event.getResultCode();
            Intent data = event.getData();
            onActivityResult(requestCode, resultCode, data);
        }
    };

    ...

}

เป็นอันเรียบร้อย เพียงเท่านี้ onActivityResult ของ Fragment ก็จะถูกเรียกโดยอัตโนมัติเมื่อ onActivityResult ของ Activity ถูกเรียก หน้าที่ที่เหลือของเราคือ Override onActivityResult แล้วทำการเช็ค requestCode ให้เรียบร้อยก่อนทำงานต่อไป

public class BodyFragment extends Fragment {

    ...

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        // Don't forget to check requestCode before continuing your job
        if (requestCode == 12345) {
            // Do your job
            tvResult.setText("Result Code = " + resultCode);
        }
    }

    ...

}

ด้วยวิธีการด้านบนนี้จะช่วยแก้ปัญหาดังกล่าวของ Nested Fragment ได้เป็นอย่างดีในทุกกรณี อีกทั้งจะทำให้โค้ดเป็นระเบียบเรียบร้อยสวยงามอีกด้วย

ข้อจำกัด

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

นำไปใช้งานง่ายๆได้ด้วย StatedFragment

ข่าวดีจ้าข่าวดี เราเอาโค้ดด้านบนไปรวมกับ StatedFragment ให้เรียบร้อยแล้วในเวอร์ชั่น 0.9.3 เป็นต้นไป คุณสามารถเอาไปใช้งานได้ง่ายๆดังนี้

เพิ่ม dependency เข้าไปใน build.gradle

dependencies {
    compile 'com.inthecheesefactory.thecheeselibrary:stated-fragment-support-v4:0.9.3'
}

หรือในกรณีที่ใช้ android.app.Fragment ให้ใส่ดังนี้

dependencies {
    compile 'com.inthecheesefactory.thecheeselibrary:stated-fragment:0.9.3'
}

วิธีการใช้คือให้ไป Override คำสั่ง onActivityResult ของ Activity แล้วเพิ่มคำสั่งไป 1 บรรทัดดังนี้

public class MainActivity extends ActionBarActivity {

    ...

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        ActivityResultBus.getInstance().postQueue(
                    new ActivityResultEvent(requestCode, resultCode, data));
    }

    ...

}

ส่วน Fragment ให้ extends StatedFragment และสามารถใช้ onActivityResult ตามเดิมได้ทันทีครับ

public class BodyFragment extends StatedFragment {

    ...

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        // Add your code here
        Toast.makeText(getActivity(), "Fragment Got it: " + requestCode + ", " + resultCode, Toast.LENGTH_SHORT).show();
    }

    ...

}

เป็นอันเรียบร้อย บอกแล้วว่าง่ายมาก =D

ขอให้ทุกท่านมีความสุขในวันศุกร์อย่างนี้ครับ ^_^

ผู้เขียน: nuuneoi (Android GDE, CTO & CEO at The Cheese Factory)
นักพัฒนาแบบ Full-Stack ที่มีประสบการณ์ในการพัฒนาแอพฯแอนดรอยด์มากว่า 6 ปีและอยู่ในวงการพัฒนาแอพฯมือถือมากว่า 12 ปี มีความสนใจทางด้าน Infrastucture, Service Side, Design, UI&UX, Hardware, Optimization, Cooking, Photographing, Blogging, Training, Public Speaking และรักที่จะแชร์เรื่องราวให้ผู้คนได้อ่านได้ฟังกันผ่าน Blog