หลักเกณฑ์การแคชฝั่งไคลเอ็นต์ของ Android API

การเรียกใช้ Android API มักจะมีความล่าช้าและการคำนวณที่สำคัญต่อการเรียกใช้แต่ละครั้ง ดังนั้น การแคชฝั่งไคลเอ็นต์จึงเป็นสิ่งที่ต้องพิจารณาอย่างสำคัญในการออกแบบ API ที่มีประโยชน์ ถูกต้อง และมีประสิทธิภาพ

แรงจูงใจ

API ที่แสดงต่อนักพัฒนาแอปใน Android SDK มักมีการใช้งานเป็นโค้ดไคลเอ็นต์ในเฟรมเวิร์ก Android ที่จะเรียก Binder IPC ไปยังบริการของระบบในกระบวนการของแพลตฟอร์ม ซึ่งมีหน้าที่ดำเนินการคํานวณบางอย่างและแสดงผลลัพธ์ต่อไคลเอ็นต์ โดยปกติแล้วเวลาในการตอบสนองของการดำเนินการนี้จะขึ้นอยู่กับปัจจัย 3 อย่างต่อไปนี้

  • ค่าใช้จ่ายเพิ่มเติมของ IPC: การเรียก IPC พื้นฐานมักจะมีความล่าช้ามากกว่าการเรียกเมธอดพื้นฐานในกระบวนการ 10,000 เท่า
  • การแย่งชิงทรัพยากรฝั่งเซิร์ฟเวอร์: งานที่ทำในบริการของระบบเพื่อตอบสนองคำขอของไคลเอ็นต์อาจไม่เริ่มทันที เช่น หากเธรดเซิร์ฟเวอร์ไม่ว่างเนื่องจากจัดการกับคำขออื่นๆ ที่เข้ามาก่อนหน้านี้
  • การคํานวณฝั่งเซิร์ฟเวอร์: งานที่จัดการคําขอในเซิร์ฟเวอร์ อาจต้องใช้การทํางานอย่างมาก

คุณกำจัดปัจจัยเวลาในการตอบสนองทั้ง 3 ประการนี้ได้โดยใช้แคชฝั่งไคลเอ็นต์ โดยที่แคชต้องมีลักษณะดังนี้

  • ถูกต้อง: แคชฝั่งไคลเอ็นต์จะไม่แสดงผลลัพธ์ที่แตกต่างจากที่เซิร์ฟเวอร์จะแสดง
  • มีประสิทธิภาพ: คำขอของไคลเอ็นต์มักแสดงจากแคช เช่น แคชมีอัตรา Hit สูง
  • มีประสิทธิภาพ: แคชฝั่งไคลเอ็นต์ใช้ทรัพยากรฝั่งไคลเอ็นต์อย่างมีประสิทธิภาพ เช่น แสดงข้อมูลที่แคชไว้อย่างกระชับและไม่จัดเก็บผลการค้นหาที่แคชไว้หรือข้อมูลที่ล้าสมัยในหน่วยความจําของไคลเอ็นต์มากเกินไป

ลองแคชผลลัพธ์ของเซิร์ฟเวอร์ในไคลเอ็นต์

หากลูกค้าส่งคําขอเดียวกันหลายครั้งบ่อยครั้ง และค่าที่แสดงผลไม่เปลี่ยนแปลงเมื่อเวลาผ่านไป คุณควรใช้แคชในคลังไลบรารีไคลเอ็นต์ที่มีคีย์เป็นพารามิเตอร์คําขอ

ลองใช้ IpcDataCache ในการใช้งาน

public class BirthdayManager {
    private final IpcDataCache.QueryHandler<User, Birthday> mBirthdayQuery =
            new IpcDataCache.QueryHandler<User, Birthday>() {
                @Override
                public Birthday apply(User user) {
                    return mService.getBirthday(user);
                }
            };
    private static final int BDAY_CACHE_MAX = 8;  // Maximum birthdays to cache
    private static final String BDAY_API = "getUserBirthday";
    private final IpcDataCache<User, Birthday> mCache
            new IpcDataCache<User, Birthday>(
                BDAY_CACHE_MAX, MODULE_SYSTEM, BDAY_API,  BDAY_API, mBirthdayQuery);

    /** @hide **/
    @VisibleForTesting
    public static void clearCache() {
        IpcDataCache.invalidateCache(MODULE_SYSTEM, BDAY_API);
    }

    public Birthday getBirthday(User user) {
        return mCache.query(user);
    }
}

ดูตัวอย่างที่สมบูรณ์ได้ที่ android.app.admin.DevicePolicyManager

IpcDataCache พร้อมใช้งานสำหรับโค้ดระบบทั้งหมด รวมถึงโมดูลหลัก นอกจากนี้ยังมี PropertyInvalidatedCache ซึ่งแทบจะเหมือนกันทุกประการ แต่เฟรมเวิร์กเท่านั้นที่จะเห็น โปรดใช้ IpcDataCache หากเป็นไปได้

ทำให้แคชเป็นโมฆะเมื่อมีการเปลี่ยนแปลงฝั่งเซิร์ฟเวอร์

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

ทำให้แคชระหว่างเฟรมทดสอบโมดูลใช้งานไม่ได้

ในชุดการทดสอบยูนิต คุณอาจทดสอบโค้ดไคลเอ็นต์กับ Test Double แทนเซิร์ฟเวอร์จริง หากใช่ อย่าลืมล้างแคชฝั่งไคลเอ็นต์ระหว่างแต่ละกรณีทดสอบ วิธีนี้ช่วยให้เฟรมเวิร์กการทดสอบแยกกันอยู่และป้องกันไม่ให้เฟรมเวิร์กการทดสอบหนึ่งรบกวนอีกเฟรมเวิร์กหนึ่ง

@RunWith(AndroidJUnit4.class)
public class BirthdayManagerTest {

    @Before
    public void setUp() {
        BirthdayManager.clearCache();
    }

    @After
    public void tearDown() {
        BirthdayManager.clearCache();
    }

    ...
}

เมื่อเขียนการทดสอบ CTS ที่ทดสอบไคลเอ็นต์ API ที่ใช้การแคชภายใน แคชคือรายละเอียดการใช้งานที่ไม่ได้แสดงต่อผู้เขียน API ดังนั้นการทดสอบ CTS จึงไม่ควรต้องใช้ความรู้พิเศษเกี่ยวกับการแคชที่ใช้ในโค้ดไคลเอ็นต์

ศึกษาการ Hit และ Miss ของแคช

IpcDataCache และ PropertyInvalidatedCache สามารถพิมพ์สถิติแบบเรียลไทม์ได้ดังนี้

adb shell dumpsys cacheinfo
  ...
  Cache Name: cache_key.is_compat_change_enabled
    Property: cache_key.is_compat_change_enabled
    Hits: 1301458, Misses: 21387, Skips: 0, Clears: 39
    Skip-corked: 0, Skip-unset: 0, Skip-bypass: 0, Skip-other: 0
    Nonce: 0x856e911694198091, Invalidates: 72, CorkedInvalidates: 0
    Current Size: 1254, Max Size: 2048, HW Mark: 2049, Overflows: 310
    Enabled: true
  ...

ช่อง

Hit:

  • คําจํากัดความ: จํานวนครั้งที่พบข้อมูลในคําขอภายในแคชสําเร็จ
  • ความสำคัญ: บ่งบอกถึงการเรียกข้อมูลอย่างรวดเร็วและมีประสิทธิภาพ ซึ่งจะช่วยลดการเรียกข้อมูลที่ไม่จำเป็น
  • โดยทั่วไปแล้ว จำนวนที่สูงกว่าจะดีกว่า

ล้าง:

  • คําจํากัดความ: จํานวนครั้งที่ล้างแคชเนื่องจากมีการทำให้ข้อมูลไม่ถูกต้อง
  • เหตุผลในการล้างข้อมูล
    • ข้อมูลไม่ถูกต้อง: ข้อมูลจากเซิร์ฟเวอร์ล้าสมัย
    • การจัดการพื้นที่: เพิ่มพื้นที่ว่างสำหรับข้อมูลใหม่เมื่อแคชเต็ม
  • จํานวนสูงอาจบ่งบอกถึงข้อมูลที่เปลี่ยนแปลงบ่อยและอาจมีความไม่มีประสิทธิภาพ

ข้อบกพร่อง:

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

การข้าม:

  • คําจํากัดความ: อินสแตนซ์ที่ไม่ได้ใช้แคชเลย แม้ว่าจะใช้ได้
  • เหตุผลในการข้าม
    • การปิดกั้น: เฉพาะสำหรับการอัปเดตเครื่องมือจัดการแพ็กเกจ Android โดยจงใจปิดใช้การแคชเนื่องจากมีการเรียกใช้จำนวนมากระหว่างการบูต
    • ไม่ได้ตั้งค่า: มีแคชอยู่แต่ยังไม่ได้เริ่มต้นใช้งาน ไม่ได้ตั้งค่า Nonce ซึ่งหมายความว่าแคชไม่เคยถูกทำให้ใช้งานไม่ได้
    • ข้าม: การตัดสินใจที่จะข้ามแคช
  • จํานวนสูงบ่งบอกถึงการใช้งานแคชที่ไม่มีประสิทธิภาพ

ทำให้ใช้งานไม่ได้:

  • คําจํากัดความ: กระบวนการทําเครื่องหมายข้อมูลที่แคชไว้ว่าล้าสมัยหรือไม่เป็นปัจจุบัน
  • ความสำคัญ: แสดงสัญญาณว่าระบบทำงานกับข้อมูลล่าสุด ซึ่งช่วยป้องกันข้อผิดพลาดและความไม่สอดคล้อง
  • โดยปกติจะทริกเกอร์โดยเซิร์ฟเวอร์ที่เป็นเจ้าของข้อมูล

ขนาดปัจจุบัน:

  • คําจํากัดความ: จํานวนองค์ประกอบปัจจุบันในแคช
  • ความสำคัญ: บ่งบอกถึงการใช้ทรัพยากรของแคชและผลกระทบที่อาจเกิดขึ้นกับประสิทธิภาพของระบบ
  • โดยปกติแล้ว ค่าที่สูงขึ้นหมายความว่าแคชใช้หน่วยความจํามากขึ้น

ขนาดสูงสุด:

  • คําจํากัดความ: พื้นที่สูงสุดที่จัดสรรสําหรับแคช
  • ความสำคัญ: กําหนดความจุของแคชและความสามารถในการจัดเก็บข้อมูล
  • การตั้งค่าขนาดสูงสุดที่เหมาะสมจะช่วยรักษาสมดุลระหว่างประสิทธิภาพของแคชกับการใช้หน่วยความจํา เมื่อถึงขนาดสูงสุด ระบบจะเพิ่มองค์ประกอบใหม่โดยการนำองค์ประกอบที่ไม่ได้ใช้ล่าสุดออก ซึ่งอาจบ่งบอกถึงความไม่มีประสิทธิภาพ

High Water Mark:

  • คําจํากัดความ: ขนาดสูงสุดที่แคชมีตั้งแต่สร้าง
  • ความสำคัญ: ให้ข้อมูลเชิงลึกเกี่ยวกับการใช้งานแคชสูงสุดและปัญหาการแย่งกันใช้หน่วยความจําที่อาจเกิดขึ้น
  • การตรวจสอบจุดสูงสุดที่ทำได้จะช่วยระบุจุดคอขวดหรือส่วนที่ควรเพิ่มประสิทธิภาพได้

การล้น:

  • คําจํากัดความ: จํานวนครั้งที่แคชมีขนาดใหญ่เกินขีดจํากัดสูงสุดและต้องย้ายข้อมูลออกเพื่อเพิ่มพื้นที่ให้กับรายการใหม่
  • ความสำคัญ: บ่งบอกถึงแรงกดดันของแคชและประสิทธิภาพที่อาจลดลงเนื่องจากการนำข้อมูลออก
  • จํานวนการล้นสูงบ่งชี้ว่าอาจต้องปรับขนาดแคชหรือประเมินกลยุทธ์การแคชอีกครั้ง

สถิติเดียวกันนี้ยังดูได้ในรายงานข้อบกพร่อง

ปรับขนาดแคช

แคชมีขนาดใหญ่ได้สูงสุด เมื่อเกินขนาดแคชสูงสุด ระบบจะลบรายการออกตามลําดับ LRU

  • การแคชรายการน้อยเกินไปอาจส่งผลเสียต่ออัตรา Hit ของแคช
  • การแคชรายการมากเกินไปจะเพิ่มปริมาณการใช้หน่วยความจําของแคช

ค้นหาสมดุลที่เหมาะสมสำหรับกรณีการใช้งานของคุณ

กำจัดการเรียกใช้ไคลเอ็นต์ที่ซ้ำซ้อน

ไคลเอ็นต์อาจส่งคําค้นหาเดียวกันไปยังเซิร์ฟเวอร์หลายครั้งในช่วงเวลาสั้นๆ ดังนี้

public void executeAll(List<Operation> operations) throws SecurityException {
    for (Operation op : operations) {
        for (Permission permission : op.requiredPermissions()) {
            if (!permissionChecker.checkPermission(permission, ...)) {
                throw new SecurityException("Missing permission " + permission);
            }
        }
        op.execute();
  }
}

ลองใช้ผลลัพธ์จากการเรียกใช้ก่อนหน้านี้ซ้ำ

public void executeAll(List<Operation> operations) throws SecurityException {
    Set<Permission> permissionsChecked = new HashSet<>();
    for (Operation op : operations) {
        for (Permission permission : op.requiredPermissions()) {
            if (!permissionsChecked.add(permission)) {
                if (!permissionChecker.checkPermission(permission, ...)) {
                    throw new SecurityException(
                            "Missing permission " + permission);
                }
            }
        }
        op.execute();
  }
}

ลองใช้การจําฝั่งไคลเอ็นต์สําหรับการตอบกลับล่าสุดของเซิร์ฟเวอร์

แอปไคลเอ็นต์อาจค้นหา API ในอัตราที่เร็วกว่าที่เซิร์ฟเวอร์ของ API จะสร้างคำตอบใหม่ที่มีความหมายได้ ในกรณีนี้ แนวทางที่มีประสิทธิภาพคือการจัดเก็บการตอบสนองล่าสุดที่ได้จากเซิร์ฟเวอร์ไว้ที่ฝั่งไคลเอ็นต์พร้อมกับการประทับเวลา และแสดงผลลัพธ์ที่เก็บไว้โดยไม่ต้องค้นหาเซิร์ฟเวอร์หากผลลัพธ์ที่เก็บไว้นั้นใหม่พอ ผู้เขียนไคลเอ็นต์ API จะกำหนดระยะเวลาการจําได้

ตัวอย่างเช่น แอปอาจแสดงสถิติการเข้าชมเครือข่ายต่อผู้ใช้โดยค้นหาสถิติในทุกเฟรมที่วาด

@UiThread
private void setStats() {
    mobileRxBytesTextView.setText(
        Long.toString(TrafficStats.getMobileRxBytes()));
    mobileRxPacketsTextView.setText(
        Long.toString(TrafficStats.getMobileRxPackages()));
    mobileTxBytesTextView.setText(
        Long.toString(TrafficStats.getMobileTxBytes()));
    mobileTxPacketsTextView.setText(
        Long.toString(TrafficStats.getMobileTxPackages()));
}

แอปอาจวาดเฟรมที่ 60 Hz แต่สมมติว่าโค้ดไคลเอ็นต์ใน TrafficStats อาจเลือกที่จะค้นหาสถิติจากเซิร์ฟเวอร์ไม่เกิน 1 ครั้งต่อวินาที และหากค้นหาภายใน 1 วินาทีหลังจากการค้นหาครั้งก่อนหน้า ระบบจะแสดงค่าที่พบล่าสุด ซึ่งได้รับอนุญาตเนื่องจากเอกสารประกอบ API ไม่ได้ระบุสัญญาใดๆ เกี่ยวกับความใหม่ของผลลัพธ์ที่แสดง

participant App code as app
participant Client library as clib
participant Server as server

app->clib: request @ T=100ms
clib->server: request
server->clib: response 1
clib->app: response 1

app->clib: request @ T=200ms
clib->app: response 1

app->clib: request @ T=300ms
clib->app: response 1

app->clib: request @ T=2000ms
clib->server: request
server->clib: response 2
clib->app: response 2

พิจารณาใช้ codegen ฝั่งไคลเอ็นต์แทนการค้นหาเซิร์ฟเวอร์

หากเซิร์ฟเวอร์ทราบผลการค้นหา ณ เวลาที่สร้าง ให้พิจารณาว่าไคลเอ็นต์ทราบผลการค้นหา ณ เวลาที่สร้างด้วยหรือไม่ และพิจารณาว่าติดตั้งใช้งาน API ทั้งหมดฝั่งไคลเอ็นต์ได้หรือไม่

พิจารณาโค้ดแอปต่อไปนี้ที่ตรวจสอบว่าอุปกรณ์เป็นนาฬิกาหรือไม่ (กล่าวคือ อุปกรณ์ใช้ Wear OS อยู่)

public boolean isWatch(Context ctx) {
    PackageManager pm = ctx.getPackageManager();
    return pm.hasSystemFeature(PackageManager.FEATURE_WATCH);
}

พร็อพเพอร์ตี้นี้ของอุปกรณ์จะทราบ ณ เวลาที่สร้าง โดยเฉพาะเมื่อสร้างเฟรมเวิร์กสำหรับอิมเมจการบูตของอุปกรณ์นี้ โค้ดฝั่งไคลเอ็นต์สำหรับ hasSystemFeature อาจแสดงผลลัพธ์ที่ทราบทันทีแทนการค้นหาบริการระบบ PackageManager ระยะไกล

กรองการเรียกกลับของเซิร์ฟเวอร์ที่ซ้ำกันออกในไคลเอ็นต์

สุดท้าย ไคลเอ็นต์ API อาจลงทะเบียนการเรียกกลับกับเซิร์ฟเวอร์ API เพื่อรับการแจ้งเตือนเกี่ยวกับเหตุการณ์

การที่แอปลงทะเบียนการเรียกกลับหลายรายการสําหรับข้อมูลพื้นฐานเดียวกันนั้นเป็นเรื่องปกติ ไลบรารีไคลเอ็นต์ควรมีการเรียกกลับที่ลงทะเบียน 1 รายการโดยใช้ IPC กับเซิร์ฟเวอร์ แล้วแจ้งการเรียกกลับที่ลงทะเบียนแต่ละรายการในแอปแทนที่จะให้เซิร์ฟเวอร์แจ้งไคลเอ็นต์ 1 ครั้งต่อ 1 การเรียกกลับที่ลงทะเบียนโดยใช้ IPC

digraph d_front_back {
  rankdir=RL;
  node [style=filled, shape="rectangle", fontcolor="white" fontname="Roboto"]
  server->clib
  clib->c1;
  clib->c2;
  clib->c3;

  subgraph cluster_client {
    graph [style="dashed", label="Client app process"];
    c1 [label="my.app.FirstCallback" color="#4285F4"];
    c2 [label="my.app.SecondCallback" color="#4285F4"];
    c3 [label="my.app.ThirdCallback" color="#4285F4"];
    clib [label="android.app.FooManager" color="#F4B400"];
  }

  subgraph cluster_server {
    graph [style="dashed", label="Server process"];
    server [label="com.android.server.FooManagerService" color="#0F9D58"];
  }
}