จัดการและวินิจฉัยหน่วยความจำ 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) Android Low Memory Killer (LMK) จะเริ่มสิ้นสุดกระบวนการทำงานเบื้องหลังเพื่อเรียกคืน หน่วยความจำ ซึ่งจะทำให้การทำงานแบบมัลติทาสก์โดยรวมของอุปกรณ์แย่ลงก่อนที่จะปิดแอปที่ทำงานอยู่เบื้องหน้าในที่สุด

จัดการวงจรของ WebView

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

เพื่อให้มั่นใจว่าได้ล้างข้อมูลอ้างอิงบริบท Java และทรัพยากรการแสดงผลดั้งเดิมทั้ง 2 อย่างอย่างสมบูรณ์ คุณต้องจัดลำดับการหยุดทำงานอย่างชัดเจนในวงจรของคอมโพเนนต์โฮสต์ (เช่น 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การใช้หน่วยความจำ ให้มุ่งเน้นที่เมตริกต่อไปนี้

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

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

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

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

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

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

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

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

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

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

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

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

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

  • การรั่วไหลของเนทีฟหรือ DOM: หาก WebViews และ Activities ยังคงคงที่ ในขณะที่ RSS ของกระบวนการทั้งหมดและอื่นๆ แบบส่วนตัวยังคงเพิ่มขึ้น การรั่วไหล เกิดจากทรัพยากรเนทีฟ องค์ประกอบ 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:"

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

แท็กความทรงจำ OOB ความเกี่ยวข้องกับเนื้อหาแอปและเว็บ สาเหตุที่พบบ่อยที่ทำให้หน่วยความจำเพิ่มขึ้น
[anon:partition_alloc] Chromium PartitionAlloc การจัดสรรสำหรับ 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 Profiler

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

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

  1. แยกประเภทการรั่วไหล (Java กับ Native): เรียกใช้ 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 ที่กำหนดเองหรือการอ้างอิงส่วนกลางของ JNI ที่เชื่อมโยงกับมุมมองที่ปิด
    • ยืนยันว่า Private Other และกระบวนการ RSS เสถียรหลังจากเปลี่ยนเส้นทาง การนำทาง

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

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