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

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

แรงจูงใจ

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

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

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

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

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

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

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

ล้างข้อมูลในแคชเมื่อมีการเปลี่ยนแปลงฝั่งเซิร์ฟเวอร์

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

ล้างแคชระหว่างกรอบการทดสอบหน่วย

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

@RunWith(AndroidJUnit4.class)
public class BirthdayManagerTest {

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

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

    ...
}

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

ศึกษาการเข้าชมและการไม่เข้าชมแคช

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 Package Manager โดยตั้งใจ ปิดการแคชเนื่องจากมีการเรียกใช้จำนวนมากในระหว่างการบูต
    • ไม่ได้ตั้งค่า: มีแคชแต่ยังไม่ได้เริ่มต้น ไม่ได้ตั้งค่า Nonce ซึ่งหมายความว่าแคชไม่เคยถูกทำให้ใช้ไม่ได้
    • ข้าม: การตัดสินใจโดยเจตนาที่จะข้ามแคช
  • จำนวนที่สูงแสดงให้เห็นถึงความไม่มีประสิทธิภาพที่อาจเกิดขึ้นในการใช้แคช

ใช้ไม่ได้แล้ว:

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

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

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

ขนาดสูงสุด:

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

จุดสูงสุด:

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

การล้น:

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

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

ปรับขนาดแคช

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

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

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

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

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

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 ครั้งต่อการเรียกกลับที่ลงทะเบียนโดยใช้ 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"];
  }
}