จัดการและวินิจฉัยหน่วยความจำ WebView

WebView จะเรียกใช้โค้ดแบบเนทีฟในหลายกระบวนการเพื่อแสดงผลเนื้อหาเว็บในแอป Android การปล่อยให้อินสแตนซ์ WebView ไม่ได้รับการจัดการอาจทำให้เกิดหน่วยความจำรั่วไหล, ข้อผิดพลาดหน่วยความจำไม่พอ (OOM) และประสิทธิภาพของแอปแย่ลง

เอกสารนี้อธิบายโมเดลหน่วยความจำแบบหลายกระบวนการของ WebView รวมถึงอธิบายวิธีจัดการวงจรการใช้งานอย่างเหมาะสมเพื่อป้องกันการรั่วไหล และให้เวิร์กโฟลว์ที่ใช้งานได้จริงสำหรับการวินิจฉัยปัญหาหน่วยความจำ

ทำความเข้าใจสถาปัตยกรรมหน่วยความจำของ WebView

หากต้องการจัดการหน่วยความจำของ WebView อย่างมีประสิทธิภาพ คุณต้องทำความเข้าใจวิธีที่ Android จัดสรรทรัพยากรสำหรับเนื้อหาเว็บ ดังนี้

  • การดำเนินการแบบหลายกระบวนการ: ใน Android 8.0 (ระดับ API 26) ขึ้นไป WebView จะแยกเนื้อหาเว็บออกจากฟังก์ชันหลักของแอปในหลายกระบวนการ (ในอุปกรณ์ที่มี RAM น้อย ระบบอาจกลับไปใช้กระบวนการเดียว)

    • กระบวนการโฮสต์ (เบราว์เซอร์): กระบวนการหลักของแอปที่ Activity และโค้ด Java หรือ Kotlin ทำงาน
    • กระบวนการแสดงผลแบบแยก: กระบวนการแซนด์บ็อกซ์แยกต่างหาก (SandboxedProcessService) ที่แยกวิเคราะห์ HTML และ CSS, เรียกใช้ JavaScript และแสดงผลหน้าเว็บ
  • หน่วยความจำที่ใช้แบบเนทีฟ: หน่วยความจำส่วนใหญ่ของ WebView รวมถึงกราฟิกที่แสดงผล, แผนผัง DOM และหน่วยความจำรันไทม์ของ JavaScript จะได้รับการจัดสรรในหน่วยความจำแบบเนทีฟ ไม่ใช่ในฮีป Java ฮีปดัมป์ Java (.hprof) จะแสดงเฉพาะออบเจ็กต์ Wrapper Java แบบเบาและไม่บันทึกหน่วยความจำจริงที่เนื้อหาเว็บใช้

  • ผลกระทบต่อระบบของหน่วยความจำแบบเนทีฟ: หน่วยความจำแบบเนทีฟสามารถเพิ่มขึ้นอย่างเงียบๆ เป็นกิกะไบต์ ซึ่งแตกต่างจากการจัดสรรฮีป Java ที่ถูกจำกัดด้วยขีดจำกัด maxHeap ของแอปและล้มเหลวอย่างรวดเร็วด้วย OutOfMemoryError เมื่อหน่วยความจำแบบเนทีฟที่ยังไม่ได้เผยแพร่เต็ม RAM จริงและพื้นที่ Swap (zRAM) Low Memory Killer (LMK) ของ Android จะเริ่มยุติกระบวนการเบื้องหลังเพื่อเรียกคืนหน่วยความจำ ซึ่งจะทำให้การทำงานแบบมัลติทาสก์โดยรวมของอุปกรณ์แย่ลงก่อนที่จะยุติแอปที่ทำงานอยู่เบื้องหน้าในที่สุด

จัดการวงจรการใช้งาน WebView

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

หากต้องการล้างข้อมูลอ้างอิงบริบท Java และทรัพยากรการแสดงผลแบบเนทีฟอย่างสมบูรณ์ คุณต้องจัดลำดับการล้างข้อมูลอย่างชัดเจนในวงจรการใช้งานของคอมโพเนนต์โฮสต์ (เช่น onDestroy()) โดยหยุดการดำเนินการหน้าเว็บที่ใช้งานอยู่ ยกเลิกการแนบมุมมองออกจากคอนเทนเนอร์ และปล่อยการผูกแบบเนทีฟ

ล้างข้อมูลอินสแตนซ์ WebView

หากต้องการให้ระบบปิดอย่างสะอาดและปล่อยทรัพยากรเมื่อ Activity หรือ Fragment ถูกทำลาย ให้ทำดังนี้

  1. นำ WebView ออกจากคอนเทนเนอร์ระดับบนสุด (ViewGroup)
  2. หยุดการโหลดที่ใช้งานอยู่และล้างประวัติการนำทาง
  3. เรียกใช้ destroy()
  4. ล้างข้อมูลอ้างอิงเป็น null

ตัวอย่างต่อไปนี้แสดงวิธีล้างข้อมูล WebView อย่างเหมาะสม

Kotlin

override fun onDestroy() {
    myWebView?.let {
        // Remove the WebView from its parent ViewGroup.
        (it.parent as? ViewGroup)?.removeView(it)
        // Stop active loading and clear history.
        it.stopLoading()
        it.clearHistory()
        // Destroy the instance.
        it.destroy()
    }
    myWebView = null
    super.onDestroy()
}

Java

@Override
protected void onDestroy() {
    if (myWebView != null) {
        // Remove the WebView from its parent ViewGroup.
        if (myWebView.getParent() instanceof ViewGroup) {
            ((ViewGroup) myWebView.getParent()).removeView(myWebView);
        }
        // Stop active loading and clear history.
        myWebView.stopLoading();
        myWebView.clearHistory();
        // Destroy the instance.
        myWebView.destroy();
    }
    myWebView = null;
    super.onDestroy();
}

ทำความเข้าใจหน่วยความจำหลังการทำลาย

เมื่อคุณเรียกใช้ destroy() ระบบจะปล่อยบริบท Activity ล้างข้อมูลลำดับชั้นของมุมมอง และหยุดการทำงานเบื้องหลังของเว็บ อย่างไรก็ตาม คุณอาจสังเกตเห็นว่าหน่วยความจำจริง (Resident Set Size) ของกระบวนการไม่ได้ลดลงทันทีเป็นค่าพื้นฐานก่อนใช้ WebView

โดยลักษณะการทำงานนี้เป็นสิ่งที่ปกติ แคชรันไทม์แบบเนทีฟ, ไลบรารีที่แชร์ และหน้าหน่วยความจำที่จัดสรรจะยังคงอยู่ในกระบวนการจนกว่าระบบปฏิบัติการจะเรียกคืนหรือกระบวนการสิ้นสุด เป้าหมายหลักของ destroy() คือการป้องกันหน่วยความจำ Activity รั่วไหลสะสมเมื่อผู้ใช้เข้าและออกจากหน้าจอที่ใช้เว็บ

เมตริกการแก้ไขข้อบกพร่องที่สำคัญ

เมื่อวิเคราะห์การใช้หน่วยความจำของ WebView ให้มุ่งเน้นที่เมตริกต่อไปนี้

  • Resident Set Size (RSS): RAM จริงทั้งหมดที่แมปกับกระบวนการ รวมถึงโค้ดและไลบรารีที่แชร์ (ระบุเป็นทั้งหมด ในเครื่องมือสร้างโปรไฟล์ของ Android Studio)

  • Anonymous RSS (RssAnon): หน่วยความจำที่กระบวนการจัดสรรโดยตรงซึ่งไม่ได้สำรองข้อมูลด้วยไฟล์ในดิสก์ (เช่น ฮีปแบบเนทีฟและการจัดสรรรันไทม์ของ JavaScript) ซึ่งแสดงถึงค่าใช้จ่ายหน่วยความจำหลักของเนื้อหาเว็บ (ระบุเป็นจัดสรร ในเครื่องมือสร้างโปรไฟล์ของ Android Studio)

  • Private Memory Footprint (PMF): ผลรวมของ Anonymous RSS และ Swap (zRAM) PMF แสดงถึงภาระหน่วยความจำจริงที่แอปของคุณกำหนดให้กับระบบ

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

  • จำนวนออบเจ็กต์ที่ใช้งานอยู่ (WebViews, Activities, Views): จำนวนอินสแตนซ์ UI, บริบท และ WebView ที่ ใช้งานอยู่ซึ่งเก็บไว้ในหน่วยความจำ การติดตามจำนวนเหล่านี้จะระบุว่าการเพิ่มขึ้นของหน่วยความจำเกิดจากข้อมูลอ้างอิง Java ที่เก็บไว้หรือการจัดสรรแบบเนทีฟเท่านั้น

  • Private Other และ Native Heap: ใน dumpsys meminfo การจัดสรร C/C++ แบบเนทีฟและการแมปหน่วยความจำที่กำหนดเอง (เช่น ฮีป PartitionAlloc ของ Chromium หรือฮีปรันไทม์ของ JavaScript แบบฝัง) จะปรากฏในส่วน Native Heap และ Private Other แทนที่จะเป็น Java Heap

ดูข้อมูลเพิ่มเติมเกี่ยวกับตัวนับหน่วยความจำของกระบวนการและหมวดหมู่ของตัวนับได้ที่ อภิธานศัพท์หน่วยความจำของกระบวนการ

เวิร์กโฟลว์การวินิจฉัยที่ใช้งานได้จริง

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

เครื่องมือสร้างโปรไฟล์และเครื่องมือวินิจฉัย

หากต้องการตรวจสอบการจัดสรรหน่วยความจำและวินิจฉัยการรั่วไหล ให้ใช้เครื่องมือต่อไปนี้

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

  • การติดตามหน่วยความจำด้วย Perfetto: ใช้ Perfetto เพื่อบันทึกตัวนับหน่วยความจำระดับระบบ (เช่น RSS และ Anonymous RSS) เพื่อสังเกตการเพิ่มขึ้นของหน่วยความจำ โดยรวม โปรดทราบว่าการจัดสรรเครื่องมือแบบเนทีฟของ WebView จะไม่สร้าง Callstack ในเครื่องมือสร้างโปรไฟล์ฮีปของ Perfetto ใช้ เครื่องมือสำหรับนักพัฒนาเว็บใน Chrome เพื่อตรวจสอบสแนปชอตฮีป JavaScript และการจัดสรร DOM ภายในเนื้อหาเว็บ

ตรวจสอบจำนวนออบเจ็กต์ที่ใช้งานอยู่

หากต้องการตรวจสอบว่าการเพิ่มขึ้นของหน่วยความจำเกิดจากออบเจ็กต์เฟรมเวิร์ก Java ที่เก็บไว้ (เช่น คอมโพเนนต์ UI) หรือการจัดสรรแบบเนทีฟ ให้ตรวจสอบส่วน Objects ของ dumpsys meminfo

adb shell dumpsys meminfo -a <var>PACKAGE_NAME</var> | grep -A 10 "Objects"

เอาต์พุตจะแสดงจำนวนออบเจ็กต์ที่ใช้งานอยู่

 Objects
               Views:     142         ViewRootImpl:        1
         AppContexts:       3           Activities:        1
              Assets:      12        AssetManagers:        0
       Local Binders:      32        Proxy Binders:       45
       Parcel memory:      15         Parcel count:       30
    Death Recipients:       2             WebViews:        1

ส่วนนี้จะแสดงจำนวนออบเจ็กต์เฟรมเวิร์กที่ใช้งานอยู่, แฮนเดิล IPC และการจัดสรร Parcel สำหรับการวินิจฉัย WebView ให้มุ่งเน้นที่ Activities และ WebViews เป็นหลัก

ดำเนินการโต้ตอบของผู้ใช้เป้าหมาย (เช่น การเปิดและปิดหน้าจอเว็บ) ซ้ำๆ และเปรียบเทียบจำนวน

  • การรั่วไหลของอินสแตนซ์: หาก WebViews หรือ Activities เพิ่มขึ้นในการนำทางแต่ละครั้งและไม่กลับสู่ค่าพื้นฐาน แสดงว่าแอปของคุณกำลังรั่วไหลอินสแตนซ์ WebView Java หรือ Activity โฮสต์ (เช่น เนื่องจากไม่มี ViewGroup.removeView() หรือข้อมูลอ้างอิง Listener ที่เก็บไว้) เนื่องจาก Activity ที่รั่วไหลจะตรึง DOM Tree ทั้งหมดและทรัพยากรรูปภาพที่ถอดรหัสไว้ในหน่วยความจำ การเข้าชมซ้ำๆ จะทำให้ฮีป Java หมดลงอย่างรวดเร็วและทำให้เกิดข้อผิดพลาด OutOfMemoryError

  • การรั่วไหลแบบเนทีฟหรือ DOM: หาก WebViews และ Activities ยังคงที่ในขณะที่ RSS ของกระบวนการทั้งหมดและ Private Other เพิ่มขึ้นเรื่อยๆ แสดงว่าการรั่วไหลเกิดจากทรัพยากรแบบเนทีฟที่ยังไม่ได้เผยแพร่, องค์ประกอบ DOM หรือการผูกเครื่องมือ JavaScript เนื่องจากการจัดสรรเหล่านี้อยู่ในหน่วยความจำแบบเนทีฟและข้ามตัวเก็บขยะ ART จึงทำให้เครื่องมือตรวจหาการรั่วไหลของ Java มาตรฐานมองไม่เห็นและสะสมต่อไปจนกว่าระบบปฏิบัติการจะยุติแอป

สร้างโปรไฟล์กระบวนการแสดงผลแบบแยกโดยใช้ CLI

การเรียกใช้ dumpsys meminfo ด้วยชื่อแพ็กเกจของแอปจะแสดงเฉพาะหน่วยความจำสำหรับกระบวนการโฮสต์หลัก หากต้องการตรวจสอบกระบวนการแสดงผลแบบแยกที่แสดงผลหน้าเว็บ ให้ทำดังนี้

  1. ค้นหารหัสกระบวนการ (PID) ของบริการแสดงผลแบบแยก

    adb shell dumpsys activity processes <var>PACKAGE_NAME</var> | grep "Isolated.*SandboxedProcessService"

    เอาต์พุตจะแสดงบันทึกกระบวนการแบบแยกและ PID RENDERER_PID (เช่น 22155)

    Isolated #5: ProcessRecord{... 22155:com.google.android.webview.debug:sandboxed_process0:...}
    
  2. ตรวจสอบรายละเอียดหน่วยความจำของกระบวนการแสดงผลโดยใช้ PID

    adb shell dumpsys meminfo <var>RENDERER_PID</var>
  3. ตรวจสอบกระบวนการแอปโฮสต์เพื่อประเมินขนาดหน่วยความจำฝั่งเบราว์เซอร์

    adb shell dumpsys meminfo <var>PACKAGE_NAME</var>

ตรวจสอบการแมปและการจัดสรรหน่วยความจำ

หากต้องการดูว่าระบบย่อยหรือตัวจัดสรรแบบเนทีฟใดใช้หน่วยความจำแบบไม่ระบุตัวตน ให้ตรวจสอบการแมปหน่วยความจำของกระบวนการ

adb shell "cat /proc/$(pidof <var>PACKAGE_NAME</var>)/maps" | grep "anon:"

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

แท็กหน่วยความจำ ระบบย่อย ความเกี่ยวข้องกับแอปและเนื้อหาเว็บ สาเหตุทั่วไปที่ทำให้หน่วยความจำเพิ่มขึ้น
[anon:partition_alloc] PartitionAlloc ของ Chromium การจัดสรรสำหรับ DOM Tree, บัฟเฟอร์การแสดงผล, ฮีป JavaScript V8 และการดำเนินการ WebAssembly ใน WebView ใช่ (สูง): การโหลดหน้าเว็บขนาดใหญ่, DOM ที่มีสื่อจำนวนมาก หรือการไม่เรียกใช้ destroy() ในอินสแตนซ์ WebView ที่ทิ้งแล้วจะทำให้แท็กนี้เพิ่มขึ้นโดยตรง
[anon:scudo...] หรือ [anon:libc_malloc] ตัวจัดสรรฮีปแบบเนทีฟของ Android (Scudo / jemalloc) การจัดสรรแบบเนทีฟ C/C++ ทั่วไปที่ไลบรารี NDK, บริดจ์ JNI และไปป์ไลน์กราฟิกแบบเนทีฟใช้ ใช่ (ปานกลางถึงสูง): การเพิ่มขึ้นจะเกิดขึ้นเมื่อ Wrapper JNI แบบเนทีฟหรือการขึ้นต่อกัน C++ ของบุคคลที่สามเก็บการจัดสรรที่ยังไม่ได้เผยแพร่ไว้ในการนำทาง
[anon:...] (เช่น [anon:quickjs_heap...]) การเขียนสคริปต์ที่กำหนดเองหรือรันไทม์แบบเนทีฟ เครื่องมือ JavaScript แบบฝัง, รันไทม์ WebAssembly ที่กำหนดเอง หรือพูลบัฟเฟอร์แบบเนทีฟที่กำหนดเอง ใช่ (ขึ้นอยู่กับบริบท): พบได้ทั่วไปในแอปแบบไฮบริดที่เรียกใช้เครื่องมือเขียนสคริปต์ควบคู่ไปกับมุมมองแบบเนทีฟและล้างข้อมูลการผูกรันไทม์ไม่ได้

ข้อจำกัดของ API หน่วยความจำในแอป

API หน่วยความจำในแอป (เช่น Debug.getMemoryInfo หรือ ActivityManager.getProcessMemoryInfo) จะวัดเฉพาะกระบวนการเรียก ในโหมดหลายกระบวนการ API เหล่านี้ไม่สามารถบันทึกหน่วยความจำที่กระบวนการแสดงผลแบบแยกใช้ หากต้องการประเมินหน่วยความจำทั้งหมดอย่างถูกต้อง ให้ใช้เครื่องมือระบบ เช่น dumpsys meminfo, Perfetto หรือเครื่องมือสร้างโปรไฟล์ของ Android Studio

การจัดลำดับความสำคัญของหน่วยความจำสูงในแอปแบบไฮบริด

เมื่อวินิจฉัยการเพิ่มขึ้นของหน่วยความจำที่อธิบายไม่ได้ระหว่างการโต้ตอบ WebView ที่เกิดขึ้นซ้ำๆ (เช่น การเปิดลิงก์เว็บหรือการเลื่อนดูฟีดที่ใช้เว็บ) ให้ใช้เวิร์กโฟลว์การจัดลำดับความสำคัญต่อไปนี้เพื่อแยกว่าการรั่วไหลเกิดจากเลเยอร์ Java หรือเครื่องมือแบบเนทีฟ

  1. แยกประเภทการรั่วไหล (Java เทียบกับแบบเนทีฟ): เรียกใช้ dumpsys meminfo -a <var>PACKAGE_NAME</var> | grep -A 10 "Objects" ก่อนและหลังการเปลี่ยนของผู้ใช้ซ้ำๆ (เช่น การเปิดและปิดบทความเว็บ หรือการปัดดูฟีด)

    • ข้อสังเกต: หากจำนวน Activities และ WebViews ยังคงที่ (เช่น อินสแตนซ์ที่ใช้งานอยู่ 1-2 รายการ) แสดงว่าแอปไม่ได้รั่วไหลบริบท Activity หรืออินสแตนซ์ WebView Java
  2. วัดส่วนต่างของหน่วยความจำในการโต้ตอบ (การติดตามแบบอนุกรมเวลา): บันทึกสแนปชอต dumpsys meminfo ในการโต้ตอบของผู้ใช้หลายรายการเพื่อคำนวณอัตราการจัดสรรต่อการเปลี่ยน

    • ข้อสังเกต: ฮีป Java ยังคงถูกจำกัดและอยู่ในสถานะปกติ (เพิ่มขึ้นอย่างรวดเร็วระหว่างการใช้งานและลดลงหลังจากการเก็บขยะ) แต่ Private Other และ Native Heap เพิ่มขึ้นอย่างต่อเนื่องหลายเมกะไบต์ต่อการเปลี่ยน ซึ่งพิสูจน์ว่าการรั่วไหลอยู่ในหน่วยความจำแบบเนทีฟทั้งหมดนอกรันไทม์ ART ฮีปดัมป์ Java มาตรฐาน (.hprof) จะไม่แสดงปัญหาใดๆ
  3. ตรวจสอบการแมปหน่วยความจำแบบไม่ระบุตัวตน: ตรวจสอบการแมปหน่วยความจำของกระบวนการโดยใช้ ADB (ดู ตรวจสอบการแมปและการจัดสรรหน่วยความจำ):

    adb shell "cat /proc/$(pidof <var>PACKAGE_NAME</var>)/maps" | grep "anon:"
    • ข้อสังเกต: การเพิ่มขึ้นของหน่วยความจำจะมุ่งเน้นไปที่ [anon:partition_alloc] หรือฮีปเครื่องมือเขียนสคริปต์แบบฝัง พร้อมกับการเพิ่มขึ้นอย่างช้าๆ ในข้อมูลอ้างอิงส่วนกลาง JNI ซึ่งบ่งชี้ว่าแม้ว่าจะมีการแทนที่มุมมอง Java แล้ว แต่ระบบยังไม่ได้เผยแพร่ออบเจ็กต์หน้าเว็บแบบเนทีฟหรือการผูก JavaScript ที่เกี่ยวข้อง
  4. การแก้ไข:

    • ตรวจสอบว่า WebView ทุกรายการที่นำกลับมาใช้ใหม่หรือทิ้งแล้วจะหยุดสคริปต์ที่ใช้งานอยู่ (stopLoading()) ล้างประวัติ และเรียกใช้ destroy() อย่างชัดเจน
    • ล้างข้อมูลการเรียกกลับของ JavaScript Bridge ที่กำหนดเองหรือข้อมูลอ้างอิงส่วนกลาง JNI ที่เชื่อมโยงกับมุมมองที่ปิดไป
    • ยืนยันว่า Private Other และ RSS ของกระบวนการมีเสถียรภาพหลังจากการเปลี่ยนการนำทาง

แหล่งข้อมูลเพิ่มเติม

ดูข้อมูลเพิ่มเติมเกี่ยวกับการแก้ไขข้อบกพร่องและการสร้างโปรไฟล์หน่วยความจำและประสิทธิภาพของ WebView ได้จากแหล่งข้อมูลต่อไปนี้