27 December 2016

แก้ปัญหา Boilerplate น่าเบื่อๆของ Shared Preferences ด้วย Shade

Updated on

        Shared Preferences เรียกได้ว่าเป็นหนึ่งในพื้นฐานสำคัญที่นักพัฒนาแอนดรอยด์เกือบทุกคนนั้นรู้จัก และใช้งานอยู่ในโปรเจคเกือบทุกตัว แต่มีสิ่งหนึ่งที่เจ้าของบล็อกรู้สึกว่าเวลาเรียกใช้งานแล้วโคตรน่าเบื่อ ก็คือโค้ดที่ต้องเขียนซ้ำซาก (Boilerplate Code) เหมือนเดิมอยู่ทุกครั้ง เมื่อต้องการสร้าง Shared Preferenec สำหรับเก็บข้อมูลซักอย่างหนึ่ง

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

รู้จักกับไลบรารีที่ชื่อว่า Shade


        Shade เป็นไลบรารีที่จะช่วยให้การสร้างคลาสสำหรับเรียกใช้งาน Shared Preference ให้สะดวกมากขึ้น โดยใช้ประโยชน์จาก Annotation Processor เพื่อสร้างคลาส + โค้ดสำหรับ Shared Preference ขึ้นมาตอนที่ Gradle ทำงาน โดยตัวโค้ดที่เรียกใช้งานจะมีรูปแบบที่ค่อนข้างง่าย และไม่ใช้ Reflection จึงทำงานได้รวดเร็ว

        สามารถเข้าไปดู Repository ของไลบรารีตัวนี้ได้ที่ Shade [GitHub]

โค้ดเดิมๆเมื่อต้องสร้าง Shared Preferences ขึ้นมาใช้งาน

        การจะเขียน Shared Preferences ให้ดีนั้นจะต้องแยกออกมาเป็นคลาสที่ทำหน้าที่ในแต่ละส่วนโดยเฉพาะ หาใช่การเรียกโดยตรงใน Activity หรือ Fragment ดังนั้นโค้ดที่เขียนกันส่วนใหญ่ก็จะมีหน้าตาประมาณนี้

GameConfigPreferences.java
import android.content.Context;
import android.content.SharedPreferences;

public class GameConfigPreferences {
    private static final String PREFERENCES_NAME = "game.config.preferences";
    private static final String KEY_LEVEL = "key_level";
    private static final String KEY_SFX = "key_sfx";
    private static final String KEY_BGM = "key_bgm";
    private static final String KEY_LANGUAGE = "key_language";

    public static int getLevel(Context context) {
        return getPreferences(context).getInt(KEY_LEVEL, 0);
    }

    public static void setLevel(Context context, int level) {
        getPreferencesEditor(context).putInt(KEY_LEVEL, level).apply();
    }

    public static boolean isSfxOn(Context context) {
        return getPreferences(context).getBoolean(KEY_SFX, false);
    }

    public static void setSfx(Context context, boolean isOn) {
        getPreferencesEditor(context).putBoolean(KEY_SFX, isOn).apply();
    }

    public static boolean isBgmOn(Context context) {
        return getPreferences(context).getBoolean(KEY_BGM, false);
    }

    public static void setBgm(Context context, boolean isOn) {
        getPreferencesEditor(context).putBoolean(KEY_BGM, isOn).apply();
    }

    public static String getLanguage(Context context) {
        return getPreferences(context).getString(KEY_LANGUAGE, "English");
    }

    public static void setLanguage(Context context, String language) {
        getPreferencesEditor(context).putString(KEY_LANGUAGE, language).apply();
    }

    private static SharedPreferences getPreferences(Context context) {
        return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE);
    }

    private static SharedPreferences.Editor getPreferencesEditor(Context context) {
        return getPreferences(context).edit();
    }
}

        เจ้าของบล็อกสมมติขึ้นมาเป็น Utility Class สำหรับเก็บค่าต่างๆที่ใช้ภายในแอปฯ (ถ้าดูคร่าวๆก็น่าจะเดาได้ว่าเป็นสไตล์เกม) ที่เก็บค่า Config ต่างๆจากผู้ใช้ไว้ ซึ่งเจ้าของบล็อกจะต้อง...

        • ประกาศ String สำหรับกำหนดชื่อ Key ที่ใช้เก็บค่าต่างๆลงใน Shared Preferences และชื่อไฟล์ Preferences
        • สร้าง getPreferences เพื่อสร้าง Shared Preferences จาก Context (เพิ่มคำสั่งสร้าง Editor ให้ด้วย)
        • สร้าง Getter/Setter สำหรับกำหนดค่าต่างๆลงใน Shared Preferences ตัวนี้

        โดยจะเห็นว่ารูปแบบของโค้ดนั้นจะเป็นรูปแบบเดิมๆ เวลาสร้างขึ้นมาก็จะต้องก๊อปแปะๆจนครบ เวลาเรียกใช้งานก็จะเรียกแบบนี้

Context context = ...
GameConfigPreferences.setBgm(context, true);
GameConfigPreferences.setSfx(context, false);
String language = GameConfigPreferences.getLanguage(context);

        แต่จะดีกว่ามั้ยถ้ามีวิธีอื่นที่สร้างคลาสดังกล่าวได้สะดวกกว่านี้?

เรียกใช้งาน Shade ในโปรเจคแอนดรอยด์

        เพิ่ม Dependency ของ Shade เข้าไปใน build.gradle ที่ต้องการได้เลย

compile 'io.t28:shade:0.9.0'
annotationProcessor 'io.t28:shade-processor:0.9.0'

แล้ว Shade ทำให้ชีวิตง่ายขึ้นได้ยังไง?

        เมื่อเจ้าของบล็อกต้องการสร้างคลาส Shared Preferences แบบเดิม แต่ใช้ Shade เข้ามาช่วย ก็จะออกมาเป็นแบบนี้ครับ

GameConfig.java
import io.t28.shade.annotation.Preferences;
import io.t28.shade.annotation.Property;

@Preferences("game.config.preferences")
public abstract class GameConfig {
    @Property(key = "key_level", defValue = "0")
    public abstract int level();

    @Property(key = "key_sfx", defValue = "false")
    public abstract boolean sfx();

    @Property(key = "key_bgm", defValue = "false")
    public abstract boolean bgm();

    @Property(key = "key_language", defValue = "English")
    public abstract String language();
}

        โคตร Clean เลยยยยยยยยยย

        แต่เมื่อลองดูคำสั่งตัวอย่างนี้ดีๆก็จะเห็นว่าคลาสที่สร้างขึ้นจะต้องเป็น Abstract Class เท่านั้น และค่าต่างๆจะสร้างขึ้น Abstract Interface แทน โดยใช้ Annotation ของ Shade อยู่ 2 ตัวด้วยกัน คือ @Preferences กับ @Property และคลาสตัวนี้เจ้าของบล็อกไม่ได้มีชื่อต่อท้ายว่า Preferences ด้วยนะ (มีเหตุผลอยู่)

@Preferences

        Annotation สำหรับกำหนดคลาสที่ต้องการให้ Shade สร้าง Shared Preferences ขึ้นมา โดยมีรูปแบบดังนี้

@Preferences(name = "game.config.preferences", mode = Context.MODE_PRIVATE)
public abstract class GameConfig {
    ...
}

        • name : กำหนดชื่อไฟล์ Shared Preferences ที่จะสร้าง
        • mode : รูปแบบในการเข้าถึงไฟล์ดังกล่าว

        สำหรับค่า name แนะนำว่าให้กำหนดทุกครั้ง ถ้าไม่กำหนดก็จะเป็นการอิงชื่อ Default ของ Shared Preferences แทน ส่วน mode เอาเข้าจริงไม่จำเป็นต้องกำหนดก็ได้ เพราะมีค่า Default เป็น Context.MODE_PRIVATE ซึ่งทางแอนดรอยด์ก็แนะนำให้กำหนดเป็นค่านี้อยู่แล้ว

@Property

        Annotation สำหรับกำหนด Value ที่ต้องการสร้างใน Shared Preferences โดยมีรูปแบบดังนี้

@Preferences("game.config.preferences")
public abstract class GameConfig {
    @Property(key = "key_level", defValue = "0")
    public abstract int level();

    ...
}

        • key : ชื่อ Key ของ Value ตัวนั้นๆ
        • defValue : ค่า Default เมื่อ Key ดังกล่าวยังไม่มีการเก็บค่าใดๆไว้

        นอกจากนี้ยังสามารถกำหนดสิ่งที่เรียกว่า Converter ได้อีกด้วย แต่ไว้เดี๋ยวพูดถึงในภายหลัง ตอนนี้อยากให้โฟกัสที่การใช้งานพื้นฐานก่อน

Build Gradle ทุกครั้งเพื่อสร้าง Shared Prefernce 

        หลังจากสร้างคลาสที่ต้องการขึ้นมาเสร็จแล้วให้ทำการ Build Gradle ครั้งหนึ่งครับ แล้ว Shade ก็จะ Generate คลาส Shared Preferences ขึ้นมาให้จากคลาสที่สร้างขึ้นในตอนแรก โดยจะสร้างไฟล์ขึ้นมา 2 ไฟล์ คือ ...Impl.java และ ...Preferences.java โดยอิง Prefix จากคลาสที่เจ้าของบล็อกสร้างขึ้น


        จึงเป็นที่มาว่าทำไมในตอนแรกเจ้าของบล็อกถึงตั้งชื่อคลาสโดยไม่ลงท้ายว่า Preferences เพราะเดี๋ยว Shade มันจะสร้างคลาสดังกล่าวให้เอง

        อ้าว ถ้าคลาสที่สร้างขึ้นมามันไม่ใช่คลาส Shared Preferences แล้วเจ้าของบล็อกสร้างคลาสขึ้นมาสำหรับอะไรล่ะนั่น?

        คำตอบก็คือ มันเป็น Abstract Model Class สำหรับ Shade เพื่อใช้ในการ Generate ไฟล์ขึ้นมานั่นเอง

การเรียกใช้งานคลาส Shared Preferences ที่สร้างขึ้นจาก Shade

        จากตัวอย่างก็จะได้คลาสให้เรียกใช้งานดังนี้

        • GameConfigPreferences : คลาส Shared Preferences ที่สามารถเรียกใช้งานได้เลย
        • GameConfigImple : Model Class สำหรับ GameConfigPreferences (ไม่ต้องไปสนใจ)
   
Context context =...
GameConfigPreferences preferences = new GameConfigPreferences(context);
boolean isContainLevelValue = preferences.containsLevel();
boolean isContainSfxValue = preferences.containsSfx();
boolean isContainBgmValue = preferences.containsBgm();
boolean isContainLanguageValue = preferences.containsLanguage();
int level = preferences.getLevel();
boolean isSfxOn = preferences.getSfx();
boolean isBgmOn = preferences.getBgm();
String language = preferences.getLanguage();
GameConfig gameConfig = preferences.get();

        จะเห็นว่าสามารถเรียกใช้งานจากคลาส GameConfigPreferences ได้โดยตรง จะเช็คว่ามีค่าอยู่หรือไม่ หรือจะดึงค่าออกมาใช้งานเลยก็ได้ และยังสามารถดึงออกมาเป็น Instance ของคลาส GameConfig ได้เลย (เบื้องหลังของ Instance ดังกล่าวนั้นถูกสร้างขึ้นมาจาก GameConfigImpl อีกที)

        เวลาต้องการเก็บค่าใดๆลงใน GameConfigPreferences ก็ให้เก็บค่าผ่านคำสั่ง edit() ได้เลย ทำออกมาให้ใช้งานได้คล้ายๆกับคลาส Shared Preferences โดยตรงเลย

Context context = ...
GameConfigPreferences preferences = new GameConfigPreferences(context);
preferences.edit()
        .putLanguage("Thai")
        .apply();

preferences.edit()
        .putBgm(true)
        .putSfx(false)
        .apply();

preferences.edit()
        .removeLevel()
        .removeLanguage()
        .apply();

preferences.edit()
        .clear()
        .apply();

        การจะเก็บค่าใดๆก็อย่าลืมต่อท้ายด้วยคำสั่ง apply() นะ น่าจะคุ้นเคยกันอยู่แล้วล่ะ

        ซึ่งคลาสดังกล่าวไม่ได้เป็นคลาส Shared Preferences โดยตรง แต่ทว่า Shade ไปสร้างคลาสขึ้นมาครอบการทำงานอีกทีหนึ่ง

ภูมิใจนำเสนอ Converter 

        เนื่องจาก Shared Preferences รองรับการเก็บค่าแค่ตัวแปรพื้นฐานไม่กี่ตัวเท่านั้น

        • Boolean
        • Float
        • Integer
        • Long
        • String
        • Set<String> 

        สมมติว่าเจ้าของบล็อกมี Model Class อยู่ตัวหนึ่ง (ที่ไม่ใหญ่มาก) และอยากจะเก็บค่าใน Model Class ตัวนั้นลงใน Shared Preferences ทันที โดยปกติแล้วก็ต้องดึงค่าหรือแปลงค่าให้เป็นค่าที่สามารถเก็บได้เนอะ

        แต่ Shade ก็สร้างสิ่งที่เรียกว่า Converter ขึ้นมาเพื่อช่วยให้นักพัฒนาสามารถรับมือกับปัญหานี้ได้ง่ายขึ้นครับ ซึ่ง Converter จะถูกเรียกใช้ใน @Property

        อันนี้คือ Model Class ตัวอย่าง ที่ชื่อว่า UserProfile สำหรับเก็บข้อมูลแค่ 2 ตัว

UserProfile.java
public class UserProfile {
    String name;
    int age;

    ...
}


        สมมติว่าเจ้าของบล็อกเพิ่ม Field สำหรับเก็บ UserProfile แต่เก็บเป็น String โดยปกติแล้วเจ้าของบล็อกจะต้องสร้าง Method สำหรับแปลง Model ดังกล่าวให้เป็น String ก่อน ถึงจะเอาไปเก็บไว้ใน Shared Preferences ได้

Context context =...
GameConfigPreferences preferences = new GameConfigPreferences(context);

UserProfile userProfile = ...
String json = convertDataToJson(userProfile);
preferences.edit()
        .putUserProfile(json)
        .apply();

        และเวลาเรียกใช้งานก็ต้องแปลงกลับอีก

        สิ่งที่ Shade ทำก็ไม่ได้ต่างกันหรอกครับ เพราะคำสั่งแปลงก็ยังคงเหมือนเดิม แต่ Converter ทำขึ้นมาเพื่อที่จะได้ไม่ต้องมานั่งสร้าง Method แปลงค่าไปมา เมื่อโยน Model ตามที่กำหนดลงไป มันก็จะแปลงให้กลายเป็นค่าที่กำหนดไว้ปลายทางทันที

        จากตัวอย่างคือเจ้าของบล็อกมีคลาสที่ชื่อว่า UserProfile และอยากจะแปลงเป็น String ก่อนที่จะเก็บลง Shared Preferences ดังนั้นเจ้าของบล็อกจึงต้องสร้าง Converter ขึ้นมาแบบนี้

UserProfileConverter.java
import io.t28.shade.converter.Converter;

public class UserProfileConverter implements Converter<UserProfile, String> {
    @NonNull
    @Override
    public String toSupported(@Nullable UserProfile userProfile) {
        return new Gson().toJson(userProfile, UserProfile.class);
    }

    @NonNull
    @Override
    public UserProfile toConverted(@Nullable String value) {
        return new Gson().fromJson(value, UserProfile.class);
    }
}

        ขออนุญาตหยิบไลบรารี Gson เข้ามาช่วยแปลง Model ให้กลายเป็น JSON String นะ

        จะเห็นว่า Converter บังคับให้ประกาศ Override Method 2 ตัวคือ toSupported สำหรับแปลง UserProfile ให้กลายเป็น String และ toConverterd สำหรับแปลง String ให้กลับมากลายเป็น UserProfile เหมือนเดิม หน้าที่ของเจ้าของบล็อกก็คือใส่คำสั่งแปลงค่าไว้ในนั้นตามต้องการซะ

        กลับมาที่คลาส GameConfig ที่สร้างขึ้นไว้ในตอนแรกสุด อยากใช้ Converter กับ Value ตัวไหนก็ให้กำหนดไว้ใน @Property พร้อมกับระบุคลาส Converter ที่ต้องการไว้แบบนี้ได้เลย

GameConfig.java
...

@Preferences("game.config.preferences")
public abstract class GameConfig {

    ...

    @Property(key = "key_user_profile", converter = UserProfileConverter.class)
    public abstract UserProfile userProfile();
}

        ถ้ามี Converter สามารถกำหนด Value ด้วย Class ที่ต้องการได้เลยนะเออ

        อ๊ะๆ อย่าลืมนะ เวลาแก้ไขอะไรในคลาสดังกล่าว จะต้อง Build Gradle ทุกครั้งเพื่อให้ Shade สร้างคลาส Shared Preferences ใหม่

        เพียงเท่านี้ เจ้าของบล็อกก็สามารถโยน Model เข้าไปเก็บไว้โดยตรงได้เลย เดี๋ยว Converter จะจัดการให้เอง


        เวลาดึงค่ามาใช้งานก็จะได้เป็นคลาส UserProfile เลยเช่นกัน


สรุป

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

        มันต้อง Build Gradle ทุกครั้งเมื่อมีการเปลี่ยนแปลง

        ซึ่งถ้านำไปใช้งานในโปรเจคใหญ่ๆที่ใช้เวลา Build นานก็อาจจะทำให้เสียเวลามากพอสมควร และบางครั้งถ้า Build แล้วไม่ได้คลาส Preferences ออกมาก็ต้องลอง Build ใหม่จนกว่าจะได้คลาสดังกล่าวออกมาให้เรียกใช้งาน

        ก็ Trade Off กันไปเนอะ

        Shade ก็เป็นไลบรารีที่เพิ่งจะสร้างขึ้นมาได้ไม่นาน (ก็ยังจะไปหาเจอเนอะ) อาจจะมีการเปลี่ยนแปลงอะไรบ้างก็ต้องคอยติดตามดูกันต่อไปครับ