จัดการและวินิจฉัยหน่วยความจำ 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 Profiler)

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

  • หน่วยความจำที่ใช้แบบส่วนตัว (PMF): ผลรวมของ 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 และ RSS แบบไม่ระบุตัวตน) เพื่อสังเกตการเติบโตของหน่วยความจำโดยรวม โปรดทราบว่าการจัดสรรเครื่องมือแบบเนทีฟของ WebView จะไม่สร้าง Call Stack ในเครื่องมือสร้างโปรไฟล์ฮีปของ 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 และการจัดสรรพัสดุที่ใช้งานอยู่ สำหรับการวินิจฉัย 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, บัฟเฟอร์การแสดงผล, ฮีป JavaScript ของ V8 และการดำเนินการ WebAssembly ใน WebView ใช่ (สูง): การโหลดหน้าเว็บที่มีขนาดใหญ่, DOM ที่มีสื่อจำนวนมาก หรือการไม่เรียก destroy() ในอินสแตนซ์ WebView ที่ถูกทิ้งจะทำให้แท็กนี้เพิ่มขึ้นโดยตรง
[anon:scudo...] หรือ [anon:libc_malloc] ตัวจัดสรรฮีปแบบเนทีฟของ Android (Scudo / jemalloc) การจัดสรรแบบเนทีฟ C/C++ ทั่วไปที่ใช้โดยไลบรารี NDK, JNI Bridge และไปป์ไลน์กราฟิกแบบเนทีฟ ใช่ (ปานกลางถึงสูง): การเติบโตจะเกิดขึ้นเมื่อ Wrapper JNI แบบเนทีฟหรือทรัพยากร Dependency ของ C++ ของบุคคลที่สามเก็บการจัดสรรที่ยังไม่ได้เผยแพร่ไว้ในการไปยังส่วนต่างๆ
[anon:...] (เช่น [anon:quickjs_heap...]) สคริปต์ที่กำหนดเองหรือรันไทม์แบบเนทีฟ เครื่องมือ JavaScript แบบฝัง, รันไทม์ WebAssembly ที่กำหนดเอง หรือพูลบัฟเฟอร์เนทีฟที่กำหนดเอง ใช่ (ขึ้นอยู่กับบริบท): พบได้ทั่วไปในแอปแบบผสมที่เรียกใช้เครื่องมือการเขียนสคริปต์ควบคู่ไปกับมุมมองแบบเนทีฟ และไม่สามารถล้างการเชื่อมโยงรันไทม์ได้

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

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

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

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

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

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

    • การสังเกต: Java Heap ยังคงถูกจำกัดและทำงานได้ดี (พุ่งสูงขึ้นระหว่างการใช้งานและลดลงหลังจากการรวบรวมขยะ) แต่ 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()
    • ยกเลิก Callback ของบริดจ์ JavaScript ที่กำหนดเองหรือการอ้างอิง JNI ทั่วโลกที่เชื่อมโยงกับมุมมองที่ปิดไปแล้ว
    • ตรวจสอบว่า Private Other และ RSS ของกระบวนการเสถียรหลังจากการเปลี่ยนการนำทาง

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

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