บทความทางเทคนิค

แคชบนดิสก์ของ PDF Viewer และ DPI ต่อจอภาพใน Delphi

ข้อร้องเรียนสองประการที่มักจะเกิดกับ PDF Viewer แบบฝังตัวอยู่เสมอคือ: เอกสารขนาดใหญ่ต้องเรนเดอร์ใหม่ตั้งแต่ต้นทุกครั้งที่เปิดใช้งาน และข้อความจะเบลอทันทีที่หน้าต่างถูกลากไปยังจอภาพที่มีค่า DPI สูง losLab PDF Library แก้ปัญหาทั้งสองข้อนี้ได้ในตัวควบคุม TPDFlibViewer: คุณสมบัติ DiskCacheFolder จะเปิดใช้งานแคชหน้าเอกสารบนดิสก์ข้ามเซสชันโดยระบุคีย์ด้วยลายนิ้วมือของเอกสาร และคุณสมบัติ ScreenDPI ซึ่งรองรับโดย GetDpiForWindow และตัวจัดการ WM_DPICHANGED จะช่วยให้ตัวควบคุมรับรู้ถึงการปรับ DPI ต่อจอภาพได้อย่างแท้จริง

ปัญญาทั้งสองประการนี้ควรได้รับความสนใจอย่างจริงจัง เนื่องจากผู้ใช้จะสังเกตเห็นได้ทันที สัญญาที่สแกนมาขนาด 400 หน้าซึ่งเมื่อวานนี้ต้องใช้เวลา 8 วินาทีจึงจะเลื่อนดูได้ วันนี้ก็ยังคงใช้เวลาอีก 8 วินาทีเช่นเดิม ทั้งที่ไม่มีการเปลี่ยนแปลงใดๆ ในไฟล์เลย และบนเดสก์ท็อปที่มี DPI ผสมกัน ตัวควบคุมที่เรนเดอร์ด้วยค่า DPI 96 ของจอภาพหลักจะดูคมชัดดีที่นั่น แต่จะดูเบลออย่างชัดเจนบนหน้าจอ 4K ที่อยู่ข้างๆ บทความนี้จะพาทุกคนไปดูว่า TPDFlibViewer สามารถแก้ปัญหาแต่ละอย่างได้อย่างไร และที่สำคัญคือเหตุใดการออกแบบจึงลงเอยที่จุดนั้น หากคุณเพิ่งเริ่มใช้งานตัวควบคุมนี้ ให้เริ่มศึกษาจากภาพรวมของตัวควบคุม PDF viewer แบบโต้ตอบได้ แล้วจึงกลับมาศึกษาเกี่ยวกับเลเยอร์ประสิทธิภาพที่นี่

จะเก็บแคชหน้า PDF ที่เรนเดอร์ไว้ข้ามเซสชันใน Delphi ได้อย่างไร?

กำหนดโฟลเดอร์ให้กับ TPDFlibViewer.DiskCacheFolder และตัวควบคุมจะจัดเก็บหน้าบิตแมปที่เรนเดอร์แล้วลงในดิสก์อย่างถาวร ทำให้เอกสารที่เพิ่งดูไปเปิดขึ้นมาได้ทันทีแทนที่จะต้องเรนเดอร์ใหม่ บนดิสก์จะมีโครงสร้างจัดวางคือ <Folder>\PDFlibPas-PageCache\<document fingerprint>\p<page>_z<zoomkey>.bin: หนึ่งโฟลเดอร์ย่อยต่อหนึ่งเอกสาร และหนึ่งไฟล์ต่อหนึ่งการรวมหน้ากับโหมดการซูม ส่วนอื่นๆ จะทำงานโดยอัตโนมัติ โดยไม่ต้องเรียกใช้ API สำหรับแคชและไม่มีข้อมูลใดที่ต้องทำการล้าง (flush)

uses
  PDFlibViewer;

procedure TMainForm.FormCreate(Sender: TObject);
begin
  FViewer := TPDFlibViewer.Create(Self);
  FViewer.Parent := Self;
  FViewer.Align := alClient;
  // One line turns on the cross-session page cache
  FViewer.DiskCacheFolder := 'C:\ProgramData\MyApp\PageCache';
  FViewer.LoadFromFile('C:\Contracts\master-agreement.pdf', '');
end;

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

ทำไมคีย์ของแคชจึงเป็นลายนิ้วมือ ไม่ใช่เลขเวอร์ชัน

ลายนิ้วมือของเอกสาร (document fingerprint) เป็นแฮช FNV-1a ขนาด 64 บิตที่ได้จากสตริง path|size|last write time จัดรูปแบบเป็นตัวเลขฐานสิบหก 16 หลัก ข้อมูลสามส่วนนี้เป็นกลยุทธ์การล้างแคช (invalidation strategy) ทั้งหมด: เมื่อไฟล์ถูกแก้ไข ขนาดหรือเวลาที่บันทึกล่าสุดจะเปลี่ยนไป ค่าแฮชก็เปลี่ยนตาม และตัวควบคุมจะเปิดโฟลเดอร์ย่อยของแคชที่ต่างออกไป โฟลเดอร์ที่ล้าสมัยจะไม่ถูกเรียกใช้อีก และในที่สุดจะถูกลบออกด้วยการตัดข้อมูลแบบ LRU ซึ่งทำให้ไม่มีความจำเป็นต้องรักษาโปรโตคอลแท็กเวอร์ชัน ไม่มีตรรกะการเปรียบเทียบเวลาที่อาจเกิดความคลาดเคลื่อน และไม่มีทางที่บิตแมปเก่าจะถูกนำมาแสดงสำหรับเอกสารที่ได้รับการแก้ไขแล้ว

นี่คือแนวคิดเดียวกันกับ "content identity as cache key" (การระบุเอกลักษณ์ของเนื้อหาเป็นคีย์ของแคช) ที่ระบบคอมไพล์ (build systems) ใช้งาน และมีประโยชน์มากในการขจัดโหมดความล้มเหลว แคชที่เก็บไฟล์ภายใต้ชื่อปกติของเอกสารจะต้องตรวจจับการแก้ไขอย่างชัดเจน และการตรวจสอบอย่างชัดเจนแต่ละครั้งก็มักจะมีบั๊กที่รอเกิดขึ้นกับเคสพิเศษ เช่น ไฟล์ที่กู้คืนมาจากข้อมูลสำรองด้วยเวลาประทับเก่า การบันทึกที่คงขนาดเดิมไว้ หรือเส้นทางไฟล์ที่เปรียบเทียบแบบคำนึงถึงตัวพิมพ์เล็กใหญ่ในเครื่องหนึ่งแต่ไม่คำนึงในอีกเครื่องหนึ่ง การแปลงสัญญาณทั้งสามส่วนนี้ให้เป็นชื่อโฟลเดอร์จะทำให้ความล้าสมัยเป็นเรื่องที่เป็นไปไม่ได้ในเชิงโครงสร้าง แทนที่จะต้องใช้ขั้นตอนการตรวจสอบ

เหตุใดการหมุนจึงถูกละเว้นจากคีย์ของดิสก์โดยเจตนา

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

แคชบิตแมปในหน่วยความจำจะใช้คีย์แบบผสม ได้แก่ Round(Zoom * 1000) * 4 + Rotation div 90 เนื่องจากที่นั่นบิตแมปที่หมุนแล้วคือสิ่งที่ตัวจัดการการวาดต้องการจริงๆ เธรด prefetch ในเบื้องหลังจะเป็นไปตามกฎเดียวกันคือ: จะเรนเดอร์เฉพาะหน้าที่ไม่มีการหมุนและรายงานคีย์การซูมธรรมดา และเธรดหลักจะใช้การหมุนเมื่อนำผลลัพธ์ไปใช้งาน — ซึ่งเป็นกฎแบบเดียวกับเรื่องเธรดหลักเป็นเจ้าของแคชที่ได้พูดถึงไว้ในการเรนเดอร์หน้าแบบขนานและความปลอดภัยของเธรด การแยกการหมุนออกจากทุกสิ่งใต้เลเยอร์หน่วยความจำหมายความว่าจะมีการเรนเดอร์มาตรฐานเพียงหนึ่งรายการต่อหน้าและการซูม ในทุกที่ซึ่งมีพื้นที่การจัดเก็บที่ราคาสูง

การตัดข้อมูลแบบ LRU ช่วยจำกัดขนาดโฟลเดอร์แคชได้อย่างไร?

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

การทำงานของดิสก์ทุกรายการในแคช — การสร้างลายนิ้วมือ, การสร้างโฟลเดอร์, การแตะเครื่องหมาย, การอ่านและเขียนหน้า, การลบข้อมูล — จะจัดการกับข้อยกเว้น (exceptions) ของตัวเองทั้งหมด สิ่งนี้เป็นการออกแบบโดยตั้งใจไม่ใช่ความประมาท: แคชเป็นกลไกเร่งความเร็วที่ดีที่สุดเท่าที่ทำได้ และกรณีดิสก์เต็ม, โฟลเดอร์แบบอ่านอย่างเดียว หรือการล็อกของแอนติไวรัส จะต้องเพียงแค่ลดประสิทธิภาพของ Viewer ลงไปเป็น "เรนเดอร์แบบเดิม" เท่านั้น โดยไม่มีทางเกิดอาการ "แสดงหน้ากระดาษล้มเหลว" ผลลัพธ์ที่แย่ที่สุดที่เกิดจากข้อผิดพลาด IO ของแคชคือแค่การไม่พบข้อมูลในแคช (missed hit) เท่านั้น

procedure TMainForm.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  // How many render passes did the disk cache save this session?
  Log(Format('Disk cache hits: %d', [FViewer.DiskCacheHits]));
end;

จะทำให้ PDF Viewer ตระหนักถึงค่า DPI ใน Delphi ได้อย่างไร?

TPDFlibViewer จะส่งทุกการคำนวณที่ขึ้นอยู่กับ DPI ผ่านฟิลด์เดียว และการออกแบบให้มีจุดเปลี่ยนจุดเดียวนั้นคือสิ่งที่ทำให้การสนับสนุนแบบต่อจอภาพสามารถจัดการได้ง่าย โค้ดเลย์เอาต์, prefetch และการเรนเดอร์ทั้งหมดจะอ่านค่า FScreenDPI เพียงค่าเดียว; ที่ระดับซูม 100% จุด PDF จุดเดียวจะจับคู่เป็นพิกเซลขนาด ScreenDPI / 72 ตัวกำหนดค่าคุณสมบัติสาธารณะ ScreenDPI จะล้างแคชบิตแมป, สร้างเลย์เอาต์ใหม่, อัปเดตช่วงการเลื่อนหน้า และทำให้ข้อมูลหน้าต่างเป็นโมฆะ (invalidate) ดังนั้นการเปลี่ยนค่า DPI จึงเป็นการกำหนดค่าเพียงครั้งเดียวและผู้ใช้งานฟังก์ชันอื่นๆ จะปรับตามโดยอัตโนมัติ คณิตศาสตร์พิกเซลแบบเดียวกันนี้มีความสำคัญเช่นกันเมื่อคุณเรนเดอร์สำหรับเครื่องพิมพ์แทนจอภาพ ซึ่งเป็นหัวข้อที่ครอบคลุมไว้ในการแสดงตัวอย่างก่อนพิมพ์และการเรนเดอร์บริบทของอุปกรณ์

การตรวจจับจะเกิดขึ้นในส่วนแทนที่ CreateWnd มากกว่าในคอนสตรักเตอร์ เนื่องจาก GetDpiForWindow จำเป็นต้องมี Window Handle ที่ถูกต้อง และคอนสตรักเตอร์จะทำงานก่อนที่ Handle จะพร้อมใช้งานนานมาก ก่อนที่ CreateWnd จะทริกเกอร์ ตัวควบคุมจะคงค่าเริ่มต้นแบบเดิมคือ 96 เพื่อให้คณิตศาสตร์เลย์เอาต์ทำงานได้อย่างสมเหตุสมผล ส่วนตัว GetDpiForWindow เป็น Windows 10 1607+ API ดังนั้นตัวควบคุมจะผูกเชื่อมต่อแบบไดนามิกด้วย GetProcAddress และจะถอยกลับไปใช้ GetDeviceCaps(LOGPIXELSY) บนระบบที่เก่ากว่า — ยูนิตนี้ยังคงสามารถโหลดได้ในทุกที่ และ Windows รุ่นเก่าจะรายงานค่า DPI ของระบบตามปกติ

จะเกิดอะไรขึ้นเมื่อหน้าต่างย้ายไปที่จอภาพ 4K?

Windows จะส่งข้อความ WM_DPICHANGED เมื่อหน้าต่างที่รองรับ DPI ต่อจอภาพเคลื่อนข้ามไปยังจอภาพที่มีอัตราส่วนการขยายที่ต่างออกไป และ TPDFlibViewer จะจัดการมันโดยตรง ตัวจัดการจะอ่านค่า DPI ใหม่จาก Word ลำดับต่ำของ wParam, กำหนดค่านั้นผ่าน SetScreenDPI — ซึ่งจะทิ้งแคชบิตแมปที่ไม่ถูกต้องแล้วและจัดวางหน้าเอกสารใหม่ — จากนั้นจึงยอมรับขนาดสี่เหลี่ยมผืนผ้าของหน้าต่างที่ Windows แนะนำผ่านมาใน lParam ผ่านทาง SetWindowPos ซึ่งช่วยรักษารูปร่างและขนาดไคลเอ็นต์ทางตรรกะให้คงที่ระหว่างการเคลื่อนย้าย ผู้ใช้จะเห็นเอกสารเรนเดอร์ใหม่อย่างคมชัดที่ระดับสเกลใหม่ แทนที่จะเป็นภาพบิตแมปที่ถูกขยายพิกเซลโดยระบบปฏิบัติการ

// Normally you never touch ScreenDPI: CreateWnd detects the host monitor
// and WM_DPICHANGED tracks moves. Override it only for special targets,
// e.g. rendering the layout as if for a 150% display:
FViewer.ScreenDPI := 144;  // clears the bitmap cache and rebuilds layout

ข้อจำกัดที่ควรทราบก่อนตัดสินใจจัดส่งซอฟต์แวร์

ข้อจำกัดในเชิงปฏิบัติ 3 ประการที่สมควรได้รับการพิจารณาในการตรวจสอบการออกแบบของคุณ ประการแรก WM_DPICHANGED จะส่งมาถึงก็ต่อเมื่อกระบวนการได้เลือกเข้าร่วมเท่านั้น: ไฟล์แมนิเฟสต์ของแอปพลิเคชันของคุณต้องประกาศการรับรู้ DPI ต่อจอภาพ (per-monitor v2 บน Windows ปัจจุบัน) ซึ่งใน Delphi IDE คือการตั้งค่าการรับรู้ DPI ใต้ตัวเลือก Application กระบวนการที่ตระหนักเฉพาะ DPI ระบบจะไม่มีวันได้รับข้อความนี้ และตัวควบคุมจะเรนเดอร์ด้วยค่า DPI ที่ตรวจพบตอนสร้างหน้าต่างเท่านั้น ประการที่สอง แคชบนดิสก์จะแลกเปลี่ยนพื้นที่เก็บข้อมูลด้วยความเร็ว — บิตแมปหน้าเอกสารขนาด 10 หน้าตามระดับการซูมต่างๆ เป็นพื้นที่จัดเก็บที่ใช้ได้จริง ดังนั้นควรชี้ DiskCacheFolder ไปยังตำแหน่งที่คุณพอใจกับการเติบโตของข้อมูล ไม่ใช่โปรไฟล์แบบโรมมิ่ง (roaming profile) ประการที่สาม โฟลเดอร์แคชจำเป็นต้องมีสิทธิ์การเขียนสำหรับผู้ใช้งานปัจจุบัน หากไม่มีสิทธิ์ ตัวควบคุมจะยังคงทำงานได้ตามปกติอย่างเงียบๆ โดยไม่มีการพบข้อมูลในแคชเลย — ให้ตรวจสอบ DiskCacheHits ในระหว่างการทดสอบหากคุณสงสัยว่าแคชไม่ได้ทำงาน

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