Τεχνικό Άρθρο

Προσωρινή Μνήμη Δίσκου Προγράμματος Προβολής PDF και DPI ανά Οθόνη στο Delphi

Δύο παράπονα ακολουθούν κάθε ενσωματωμένο πρόγραμμα προβολής PDF: τα μεγάλα έγγραφα αποδίδονται ξανά από την αρχή σε κάθε εκκίνηση, και το κείμενο θολώνει τη στιγμή που το παράθυρο σύρεται σε μια οθόνη υψηλού DPI. Η losLab PDF Library απαντά και στα δύο στο στοιχείο ελέγχου TPDFlibViewer: η ιδιότητα DiskCacheFolder ενεργοποιεί μια προσωρινή μνήμη σελίδας δίσκου μεταξύ συνεδριών με κλειδί το αποτύπωμα του εγγράφου, και η ιδιότητα ScreenDPI, υποστηριζόμενη από το GetDpiForWindow και έναν χειριστή WM_DPICHANGED, δίνει στο στοιχείο ελέγχου πραγματική επίγνωση DPI ανά οθόνη (per-monitor DPI awareness)

Και τα δύο προβλήματα αξίζει να ληφθούν σοβαρά υπόψη, επειδή οι χρήστες τα παρατηρούν αμέσως. Ένα σκαναρισμένο συμβόλαιο 400 σελίδων που χρειαζόταν οκτώ δευτερόλεπτα για να γίνει κυλιόμενο χθες, χρειάζεται ξανά οκτώ δευτερόλεπτα σήμερα, παρόλο που τίποτα στο αρχείο δεν άλλαξε. Και σε μια επιφάνεια εργασίας με μικτό DPI, ένα πρόγραμμα προβολής που αποδίδει στα 96 DPI της κύριας οθόνης φαίνεται καλό εκεί, αλλά εμφανώς θολό στην 4K οθόνη δίπλα του. Αυτό το άρθρο εξηγεί πώς το TPDFlibViewer λύνει το καθένα από αυτά και, κυρίως, γιατί ο σχεδιασμός κατέληξε εκεί που κατέληξε. Εάν είστε νέοι στο ίδιο το στοιχείο ελέγχου, ξεκινήστε με την επισκόπηση του διαδραστικού στοιχείου ελέγχου προβολής PDF και επιστρέψτε για το επίπεδο απόδοσης

Πώς αποθηκεύετε στην προσωρινή μνήμη τις αποδοθείσες σελίδες PDF σε όλες τις συνεδρίες στο Delphi;

Αντιστοιχίστε έναν φάκελο στο TPDFlibViewer.DiskCacheFolder και το στοιχείο ελέγχου θα αποθηκεύσει τα bitmap των αποδοθέντων σελίδων στο δίσκο, έτσι ώστε ένα έγγραφο που προβλήθηκε πρόσφατα να ανοίγει ξανά αμέσως αντί να αποδίδεται εκ νέου. Στο δίσκο η διάταξη είναι <Folder>\PDFlibPas-PageCache\<document fingerprint>\p<page>_z<zoomkey>.bin: ένας υποφάκελος ανά έγγραφο, ένα αρχείο ανά συνδυασμό σελίδας και ζουμ. Όλα τα άλλα είναι αυτόματα· δεν υπάρχει API προσωρινής μνήμης για κλήση και τίποτα για εκκαθάριση

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 αναφέρει πόσες αποδόσεις σελίδων εξοικονόμησε η προσωρινή μνήμη δίσκου στην τρέχουσα συνεδρία, γεγονός που καθιστά το κέρδος μετρήσιμο και όχι απλώς ανέκδοτο. Σε ένα έγγραφο που κλείσατε και ανοίξατε ξανά, κάθε σελίδα που κυλά στην προβολή χωρίς πέρασμα απόδοσης τον αυξάνει

Γιατί το κλειδί της προσωρινής μνήμης είναι ένα αποτύπωμα και όχι ένας αριθμός έκδοσης

Το αποτύπωμα του εγγράφου είναι ένας κατακερματισμός (hash) FNV-1a 64-bit πάνω στη συμβολοσειρά path|size|last write time, μορφοποιημένος ως δεκαεξαδικά ψηφία. Αυτή η τριάδα είναι ολόκληρη η στρατηγική ακύρωσης: όταν το αρχείο τροποποιείται, το μέγεθος ή ο χρόνος εγγραφής του αλλάζει, ο κατακερματισμός αλλάζει, και το πρόγραμμα προβολής απλώς ανοίγει έναν διαφορετικό υποφάκελο προσωρινής μνήμης. Ο παλιός φάκελος δεν συμβουλεύεται ποτέ ξανά και τελικά υπόκειται σε περικοπή LRU. Δεν υπάρχει πρωτόκολλο ετικέτας έκδοσης για συντήρηση, καμία λογική σύγκρισης χρονικής σήμανσης που μπορεί να αποκλίνει, και κανένας τρόπος να σερβιριστεί ένα παλό bitmap σε ένα τροποποιημένο έγγραφο

Αυτή είναι η ίδια ιδέα "ταυτότητα περιεχομένου ως κλειδί προσωρινής μνήμης" που χρησιμοποιούν τα συστήματα build, και αποδεικνύει την αξία της με τις αποτυχίες που αποτρέπει. Μια προσωρινή μνήμη που αποθηκεύει αρχεία με το απλό όνομα του εγγράφου πρέπει να ανιχνεύει τις επεξεργασίες ρητά, και κάθε ρητός έλεγχος είναι ένα σφάλμα που περιμένει μια ειδική περίπτωση (edge case): ένα αρχείο που αποκαταστάθηκε από αντίγραφο ασφαλείας με παλιά χρονική σήμανση, μια αποθήκευση που διατηρεί το μέγεθος, μια διαδρομή που συγκρίνεται με διάκριση πεζών-κεφαλαίων σε ένα μηχάνημα και χωρίς διάκριση σε άλλο. Ο κατακερματισμός και των τριών σημάτων στο όνομα του φακέλου καθιστά την παλαιότητα δομικά αδύνατη, αντί να ελέγχεται με διαδικαστικό τρόπο

Γιατί η περιστροφή αποκλείεται σκόπιμα από το κλειδί του δίσκου

Το όνομα του αρχείου προσωρινής μνήμης κωδικοποιεί τον αριθμό σελίδας και το ζουμ, και τίποτα άλλο — η γωνία περιστροφής της προβολής απουσιάζει σκόπιμα. Η προσωρινή μνήμη δίσκου αποθηκεύει πάντα τα byte της μη περιστραμμένης απόδοσης· όταν μια αποθηκευμένη σελίδα φορτώνεται ενώ η προβολή είναι περιστραμμένη, τα pixel αναπροσανατολίζονται στη μνήμη μετά την αποκωδικοποίηση. Εάν η περιστροφή ήταν μέρος του κλειδιού στο δίσκο, ένας χρήστης που κάνει εναλλαγή στις τέσσερις γωνίες προβολής θα έγραφε την ίδια σελίδα στο δίσκο τέσσερις φορές, τετραπλασιάζοντας την ανάπτυξη της προσωρινής μνήμης για μηδενική επιπλέον πληροφορία, καθώς η περιστροφή είναι μια φθηνή ανακατάταξη pixel σε σύγκριση με ένα πλήρες πέρασμα απόδοσης

Η προσωρινή μνήμη bitmap στη μνήμη (in-memory bitmap cache) χρησιμοποιεί πράγματι ένα σύνθετο κλειδί, Round(Zoom * 1000) * 4 + Rotation div 90, επειδή εκεί το περιστραμμένο bitmap είναι ακριβώς αυτό που χρειάζεται ο χειριστής σχεδίασης (paint handler). Το νήμα προφόρτωσης φόντου (background prefetch thread) ακολουθεί την ίδια πειθαρχία: αποδίδει πάντα μη περιστραμμένες σελίδες και αναφέρει απλά κλειδιά ζουμ, και το κύριο νήμα εφαρμόζει περιστροφή κατά την άντληση των αποτελεσμάτων — ο ίδιος κανόνας "το κύριο νήμα κατέχει την προσωρινή μνήμη" που συζητήθηκε στο παράλληλη απόδοση σελίδας και ασφάλεια νημάτων. Διατηρώντας την περιστροφή έξω από οτιδήποτε κάτω από το επίπεδο μνήμης σημαίνει μία κανονική απόδοση (canonical rendering) ανά σελίδα και ζουμ, παντού όπου είναι ακριβό να αποθηκευτεί

Πώς η περικοπή LRU διατηρεί τον φάκελο της προσωρινής μνήμης περιορισμένο;

Η προσωρινή μνήμη διατηρεί έως και δέκα φακέλους εγγράφων και περικόπτει τους λιγότερο πρόσφατα χρησιμοποιημένους (least recently used) όταν φτάνουν νέα έγγραφα. Η προσφατότητα παρακολουθείται από ένα αρχείο last-used.marker σε κάθε φάκελο εγγράφου, του οποίου η χρονική σήμανση ενημερώνεται τόσο κατά τις εγγραφές όσο και κατά τις αναγνώσεις της προσωρινής μνήμης, οπότε ένα έγγραφο που απλώς ξαναδιαβάζετε παραμένει ενεργό χωρίς να ξαναγράφεται. Ο φάκελος που ανήκει στο τρέχον ανοικτό έγγραφο εξαιρείται από τη διαγραφή, έτσι ώστε η περικοπή να μην μπορεί ποτέ να αφαιρέσει σελίδες κάτω από την ενεργή προβολή

Κάθε λειτουργία δίσκου στην προσωρινή μνήμη — αποτύπωμα, δημιουργία φακέλου, ενημέρωση σήμανσης, αναγνώσεις και εγγραφές σελίδων, περικοπή — απορροφά τις δικές της εξαιρέσεις (exceptions). Αυτή είναι μια σκόπιμη στάση, όχι προχειρότητα: η προσωρινή μνήμη είναι ένας επιταχυντής βέλτιστης προσπάθειας (best-effort), και ένας γεμάτος δίσκος, ένας φάκελος μόνο για ανάγνωση ή ένα κλείδωμα από antivirus πρέπει να υποβαθμίζουν το πρόγραμμα προβολής σε "απόδοση όπως πριν", ποτέ σε "αποτυχία εμφάνισης της σελίδας". Το χειρότερο αποτέλεσμα που μπορεί να παράγει ένα σφάλμα 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 DPI-aware στο Delphi;

Το TPDFlibViewer δρομολογεί κάθε υπολογισμό που εξαρτάται από το DPI μέσω ενός μόνο πεδίου, και αυτός ο σχεδιασμός ενός σημείου αλλαγής είναι που καθιστά τη σχετική υποστήριξη ανά οθόνη εφικτή. Ο κώδικας διάταξης, προφόρτωσης και απόδοσης διαβάζει μια ενιαία τιμή FScreenDPI· σε ζουμ 100%, ένα σημείο PDF αντιστοιχεί σε ScreenDPI / 72 pixel. Ο δημόσιος setter της ιδιότητας ScreenDPI εκκαθαρίζει την προσωρινή μνήμη bitmap, ανακατασκευάζει τη διάταξη, ενημερώνει τα εύρη κύλισης και ακυρώνει (invalidates) το παράθυρο, οπότε η αλλαγή του DPI είναι μια απλή ανάθεση με κάθε καταναλωτή να ακολουθεί αυτόματα. Τα ίδια μαθηματικά pixel έχουν σημασία όταν κάνετε απόδοση για έναν εκτυπωτή αντί για μια οθόνη, ένα θέμα που καλύπτεται στο προεπισκόπηση εκτύπωσης και απόδοση πλαισίου συσκευής

Η ανίχνευση συμβαίνει σε μια παράκαμψη CreateWnd και όχι στον κατασκευαστή (constructor), επειδή το GetDpiForWindow χρειάζεται μια έγκυρη λαβή παραθύρου (window handle) και ο κατασκευαστής εκτελείται πολύ πριν υπάρξει αυτή. Μέχρι να ενεργοποιηθεί το CreateWnd, το στοιχείο ελέγχου διατηρεί την παλαιά προεπιλογή 96, ώστε τα μαθηματικά διάταξης να παραμένουν λογικά. Το ίδιο το GetDpiForWindow είναι ένα API των Windows 10 1607+, οπότε το στοιχείο ελέγχου το δεσμεύει δυναμικά με το GetProcAddress και επιστρέφει στο GetDeviceCaps(LOGPIXELSY) σε παλαιότερα συστήματα — η μονάδα εξακολουθεί να φορτώνεται παντού, και τα παλαιότερα Windows απλώς αναφέρουν το DPI του συστήματος

Τι συμβαίνει όταν το παράθυρο μετακινείται σε μια οθόνη 4K;

Τα Windows στέλνουν το μήνυμα WM_DPICHANGED όταν ένα παράθυρο με επίγνωση DPI ανά οθόνη περνά σε μια οθόνη με διαφορετικό συντελεστή κλίμακας, και το TPDFlibViewer το χειρίζεται απευθείας. Ο χειριστής διαβάζει το νέο DPI από το low word του wParam, το εκχωρεί μέσω του SetScreenDPI — το οποίο απορρίπτει τη λάθος πλέον προσωρινή μνήμη bitmap και αναδιατάσσει τις σελίδες — και στη συνέχεια αποδέχεται το προτεινόμενο ορθογώνιο παραθύρου που περνούν τα Windows στο lParam μέσω του SetWindowPos, το οποίο διατηρεί το λογικό μέγεθος πελάτη σταθερό κατά τη μετακίνηση. Ο χρήστης βλέπει το έγγραφο να αποδίδεται ξανά καθαρά στη νέα κλίμακα, αντί για ένα bitmap τεντωμένο από το λειτουργικό σύστημα

// 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

Όρια που αξίζει να γνωρίζετε πριν από την αποστολή

Τρία πρακτικά όρια αξίζουν μια θέση στην αναθεώρηση του σχεδιασμού σας. Πρώτον, το WM_DPICHANGED φτάνει μόνο εάν η διεργασία (process) επιλέξει να συμμετάσχει: το μανιφέστο (manifest) της εφαρμογής σας πρέπει να δηλώνει επίγνωση DPI ανά οθόνη (per-monitor v2 στα τρέχοντα Windows), η οποία στο Delphi IDE είναι η ρύθμιση DPI awareness στις επιλογές εφαρμογής (Application options). Μια διεργασία με επίγνωση DPI συστήματος (system-DPI-aware) δεν λαμβάνει ποτέ το μήνυμα, και το στοιχείο ελέγχου αποδίδει στη συνέχεια στο DPI που ανίχνευσε κατά τη δημιουργία του παραθύρου. Δεύτερον, η προσωρινή μνήμη δίσκου ανταλλάσσει χώρο στο δίσκο με ταχύτητα — bitmap σελίδων που αντιστοιχούν σε δέκα έγγραφα σε διάφορα επίπεδα ζουμ αποτελούν πραγματικό χώρο αποθήκευσης, οπότε στρέψτε το DiskCacheFolder σε μια τοποθεσία που αισθάνεστε άνετα να μεγαλώσει, και όχι σε ένα roaming profile. Τρίτον, ο φάκελος της προσωρινής μνήμης χρειάζεται δικαιώματα εγγραφής για τον τρέχοντα χρήστη· χωρίς αυτά το πρόγραμμα προβολής εξακολουθεί να λειτουργεί, σιωπηλά, με μηδενικές επιτυχίες — ελέγξτε το DiskCacheHits κατά τις δοκιμές εάν υποψιάζεστε ότι η προσωρινή μνήμη δεν εμπλέκεται

Μαζί, τα δύο χαρακτηριστικά αλλάζουν την αντιληπτή ποιότητα μιας εφαρμογής με πολλά έγγραφα περισσότερο από τις περισσότερες βελτιστοποιήσεις απόδοσης: τα έγγραφα που επισκέπτονται ξανά οι χρήστες σας ανοίγουν αμέσως, και η προβολή παραμένει ευκρινής σε οποιαδήποτε οθόνη κι αν προσγειωθεί. Και τα δύο αποστέλλονται ως συνηθισμένες ιδιότητες στο TPDFlibViewer στη losLab PDF Library, επομένως η υιοθέτησή τους είναι θέμα μιας απογευματινής σύνδεσης παρά ένα έργο μηχανής απόδοσης