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

PDFium Delphi Viewer: Cache απόδοσης και τακτικές ομαλού ζουμ

Κρατήστε πατημένο το κουμπί zoom σε έναν αφελή viewer PDF και παρακολουθήστε το γράφημα CPU. Ένα μόνο πάτημα ενός auto-repeat χειριστηρίου zoom πυροδοτεί μια δωδεκάδα ή περισσότερα βήματα zoom ανά δευτερόλεπτο, και αν κάθε βήμα ξεκινά μια πλήρους ποιότητας επανα-απόδοση της ορατής σελίδας, οι αποδόσεις συσσωρεύονται πιο γρήγορα από όσο ολοκληρώνονται. Η σελίδα αποδίδεται μια χαρά μεμονωμένα, ίσως 180 ms για μια σάρωση A4, αλλά τώρα τρέχετε μια δωδεκάδα αποδόσεις των 180 ms πάνω σε δουλειά που ο χρήστης έχει ήδη ξεπεράσει. Ο viewer κλειδώνει, ένας πυρήνας κολλάει στο 100%, και μέχρι να προλάβει η οθόνη ο χρήστης έχει σταματήσει σε ένα επίπεδο zoom τεσσάρων αποδόσεων πριν. Η θεραπεία δεν είναι ένας πιο γρήγορος rasterizer. Είναι μια cache που επιστρέφει ολοκληρωμένες σελίδες ακαριαία και ένας βρόχος απόδοσης πρόθυμος να εγκαταλείψει δουλειά τη στιγμή που ξεπερνιέται

Το PDFium Component σας δίνει τα κομμάτια και για τα δύο και μένει εκτός της πολιτικής. Παίρνετε bitmaps ιδιοκτησίας του καλούντος, έναν προοδευτικό renderer που δέχεται token ακύρωσης, λειτουργίες προσαρμογής που υπολογίζουν εκ νέου το zoom στην αλλαγή μεγέθους, και μια κλήση tiling για σελίδες πολύ μεγάλες για να αποδοθούν ολόκληρες. Αυτό που σκόπιμα δεν παρέχει είναι η ίδια η cache, επειδή η σωστή πολιτική εξορίας εξαρτάται από το viewport σας, το όριο μνήμης της πλατφόρμας σας, και το πώς κάνουν κύλιση οι χρήστες σας. Αυτή η απόφαση είναι δική σας να την κάνετε σωστά, και οι συνέπειες του να την κάνετε λάθος είναι ακριβώς το πάγωμα και η διαρροή

Πού πηγαίνουν τα milliseconds και τα megabytes

Βάλτε αριθμούς στο κόστος πριν σχεδιάσετε οτιδήποτε. Μια σελίδα A4 στα 96 DPI είναι περίπου 794 επί 1123 pixel, περίπου 3,5 MB ως bitmap 32-bit. Κάντε zoom στο 200% και αυτό τετραπλασιάζεται. Στο 400% σε μια οθόνη υψηλού DPI δεσμεύετε και γεμίζετε ένα μόνο bitmap σελίδας 50 έως 60 MB, και ένας viewer συνεχούς κύλισης κρατά αρκετές σελίδες ζωντανές ταυτόχρονα. Το κόστος ραστεροποίησης ακολουθεί τα pixel εξόδου, οπότε κάθε διπλασιασμός του zoom περίπου τετραπλασιάζει μαζί τον χρόνο απόδοσης και τη μνήμη

Δύο συνέπειες προκύπτουν κατευθείαν από αυτή την αριθμητική. Μια cache της οποίας το κλειδί αγνοεί το επίπεδο zoom είναι άχρηστη, επειδή ακριβώς η χειρονομία που χρειάζεται να επιταχύνει, το zoom, παράγει ένα νέο bitmap κάθε φορά. Και μια απεριόριστη cache θα εξαντλήσει τον χώρο διευθύνσεων μιας 32-bit διεργασίας ακριβώς στα έγγραφα όπου ο κόσμος κάνει zoom πιο έντονα: πυκνές σαρώσεις τίτλων ιδιοκτησίας, μηχανολογικά σχέδια, χάρτες μεγάλης μορφής. Η cache πρέπει να έχει σωστό κλειδί και σταθερό ανώτατο όριο, και κανένα από τα δύο δεν είναι προαιρετικό

Τι ανήκει στο κλειδί της cache

Ένα bitmap σε cache είναι ασφαλές να επαναχρησιμοποιηθεί μόνο όταν κάθε είσοδος που διαμόρφωσε τα pixel του εξακολουθεί να ταιριάζει. Αυτό σημαίνει τον αριθμό σελίδας, το ενεργό zoom (ή ισοδύναμα τις διαστάσεις pixel εξόδου), την περιστροφή, το DPI της οθόνης, και τις επιλογές απόδοσης που ίσχυαν όταν παρήχθη. Μια σελίδα αποδοσμένη με reAnnotations είναι διαφορετική εικόνα από την ίδια σελίδα χωρίς αυτές, και ένα πέρασμα grayscale μέσω του reGrayscale είναι πάλι διαφορετικό. Αφαιρέστε οποιοδήποτε από αυτά από το κλειδί και τα σφάλματα είναι προβλέψιμα: μια επικάλυψη σχολιασμού που παραμένει αφού ένας αναθεωρητής διαγράψει το σχόλιο, ή μια σελίδα που θολώνει τη στιγμή που ένας χρήστης σέρνει το παράθυρο από μια οθόνη laptop σε μια εξωτερική οθόνη 4K και το DPI αλλάζει κάτω από ένα παλιωμένο bitmap

Αναζήτηση cache απόδοσης PDFium σε viewer Delphi όπου το κλειδί cache συνδυάζει σελίδα, zoom, περιστροφή, DPI οθόνης και επιλογές απόδοσης, μια επιτυχία επιστρέφει το bitmap σε microseconds, και η έξωση απελευθερώνει κάθε bitmap που απορρίπτει
Το κλειδί cache καλύπτει κάθε είσοδο που σχηματίζει τα pixel, και η έξωση αποδεσμεύει τα bitmaps που πετάει
function TPageCache.Acquire(Pdf: TPdf; PageNo: Integer; ZoomPct: Single;
  Rotation: TRotation; Opts: TRenderOptions): TBitmap;
var
  Key: string;
begin
  Key := Format('%d|%.0f|%d|%d|%d',
    [PageNo, ZoomPct, Ord(Rotation), Screen.PixelsPerInch, OptionsMask(Opts)]);
  if FBitmaps.TryGetValue(Key, Result) then
    Exit;

  Pdf.PageNumber := PageNo;
  Result := Pdf.RenderPage(0, 0, OutputWidth(PageNo, ZoomPct),
    OutputHeight(PageNo, ZoomPct), Rotation, Opts);
  FBitmaps.Add(Key, Result);   // η cache κατέχει πλέον αυτό το bitmap
end;

Σε ένα hit αυτό επιστρέφει σε microseconds, που είναι όλο το νόημα. Η δυσκολότερη ερώτηση είναι τι συμβαίνει με τα bitmaps που πέφτουν έξω από την cache, και αυτό αποδεικνύεται ότι είναι ένα ζήτημα του ποιος τα κατέχει

Ποιος ελευθερώνει το bitmap

Η μορφή συνάρτησης του RenderPage επιστρέφει ένα TBitmap που κατέχει ο καλών. Σε μια εφάπαξ εξαγωγή αυτή η κυριότητα είναι προφανής και εύκολο να τηρηθεί. Μέσα σε μια cache γίνεται η πιο κοινή διαρροή σε viewer PDF Delphi, επειδή το dictionary τώρα κρατά τη μοναδική αναφορά σε κάθε bitmap, και ένα απλό TDictionary ελευθερώνει κλειδιά και τιμές για εσάς μόνο αν είναι managed types. Ένα TBitmap δεν είναι. Εξορίστε μια εγγραφή χωρίς να καλέσετε Free και τα pixel παραμένουν δεσμευμένα χωρίς τίποτα να δείχνει σε αυτά

Ο λόγος που αυτό ξεφεύγει είναι ο χρονισμός. Ένα δεκάλεπτο smoke test ποτέ δεν κάνει zoom σε αρκετές διακριτές σελίδες για να το προσέξει· η διαρροή εμφανίζεται μόνο αφού κάποιος έχει κάνει κύλιση και zoom σε ένα μεγάλο έγγραφο για μερικές ώρες, οπότε η διεργασία κρατά εκατοντάδες ορφανά bitmap σελίδων και το μηχάνημα αρχίζει να κάνει paging. Γι' αυτό η εξορία ανήκει στην πρώτη έκδοση της cache, όχι σε μια μεταγενέστερη. Θέστε ανώτατο όριο στην cache με βάση τα εκτιμώμενα bytes, υπολογισμένα ως πλάτος επί ύψος επί τέσσερα, εξορίστε τις σελίδες που χρησιμοποιήθηκαν λιγότερο πρόσφατα και βρίσκονται εκτός του viewport και του παραθύρου prefetch, και ελευθερώστε κάθε bitmap καθώς το αφαιρείτε. Για σχέδια που είναι γνήσια παροδικά, οι υπερφορτώσεις που αποδίδουν σε ένα TBitmap που παρέχει ο καλών ή απευθείας σε ένα HDC σας επιτρέπουν να παρακάμψετε εντελώς τον χορό κυριότητας. Η προεπισκόπηση εκτύπωσης είναι η προφανής περίπτωση, αφού αποδίδετε κάθε φύλλο μία φορά και το caching του δεν προσφέρει τίποτα

Προοδευτική απόδοση και ειλικρινής ακύρωση

Οι απλές υπερφορτώσεις του RenderPage μπλοκάρουν μέχρι να ολοκληρωθεί η σελίδα, που είναι ακριβώς η συμπεριφορά που δεν θέλετε ενώ ο χρήστης εξακολουθεί να κινεί το χειριστήριο zoom. Για αυτό στρέφεστε στο RenderPageProgressive. Δέχεται ένα IPdfCancellationToken και επιστρέφει ένα από τα prsDone, prsCancelled, ή prsFailed. Η συμπεριφορική λεπτομέρεια που πιάνει τον κόσμο είναι ότι η ακύρωση δεν είναι ακαριαία. Το token ελέγχεται σε όρια τμημάτων μέσα στην απόδοση, οπότε ένα token που σηματοδοτείτε στη μέση ενός τμήματος έχει αποτέλεσμα μόνο όταν ολοκληρωθεί εκείνο το τμήμα. Σε μια σύνθετη σελίδα, η καθυστέρηση μεταξύ αιτήματος και σταματήματος φτάνει δεκάδες milliseconds. Σχεδιάστε γύρω από εκείνο το κενό αντί να ευχηθείτε να μην υπάρχει: ακυρώστε το προηγούμενο token τη στιγμή που φτάνει μια νέα τιμή zoom, αλλά μην υποθέτετε ότι η παλιά απόδοση σταματά τη στιγμή που το ζητάτε

Χρονογραμμή προοδευτικής απόδοσης PDFium σε Delphi όπου κάθε νέο αίτημα zoom ακυρώνει το προηγούμενο token, η ακύρωση προσγειώνεται σε όριο τμήματος, οι ξεπερασμένες αποδόσεις επιστρέφουν prsCancelled, και η τελική προσπάθεια επιστρέφει prsDone
Κάθε νέο αίτημα zoom ακυρώνει το προηγούμενο token απόδοσης, και η ακύρωση προσγειώνεται σε όριο chunk
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // εγκαταλείψτε την προηγούμενη απόδοση σε εξέλιξη
  FTokenSource := TPdfCancellationTokenSource.New;  // μονάδα FPdfAsync

  Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
    FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
    ro0, [reAnnotations]);

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // αντικαταστάθηκε από νεότερο αίτημα: απορρίψτε σιωπηλά
    prsFailed:    ShowRenderFailure;
  end;
end;

Κατά τη διάρκεια της αλληλεπίδρασης, το prsCancelled είναι το κανονικό αποτέλεσμα, όχι το εξαιρετικό. Οι περισσότερες αποδόσεις που ξεκινά μια χειρονομία zoom θα αντικατασταθούν πριν ολοκληρωθούν, οπότε αντιμετωπίστε την ακύρωση ως ρουτίνα και απορρίψτε το αποτέλεσμα σιωπηλά. Μια ουρά απόδοσης που καταγράφει κάθε ακύρωση ως προειδοποίηση θα θάψει τη μία αστοχία που πράγματι έχει σημασία κάτω από χιλιάδες γραμμές θορύβου. Για να μην φαίνεται νεκρή η οθόνη ενώ τρέχει η πραγματική απόδοση, συνδυάστε την προοδευτική διαδρομή με ένα φθηνό υποκατάστατο: κλιμακώστε το προηγούμενο cached bitmap στο νέο zoom και παρουσιάστε το αμέσως. Φαίνεται θολό για εκατό ή διακόσια milliseconds, αλλά διαβάζεται ως στιγμιαίο, και αγοράζει στην πλήρους ποιότητας απόδοση τον χρόνο που χρειάζεται είτε να ολοκληρωθεί είτε να ακυρωθεί από την επόμενη χειρονομία

Η λειτουργία προσαρμογής που το zoom σβήνει σιωπηλά

Η ιδιότητα FitMode ενός viewer, ορισμένη σε pfmFitPage ή pfmFitWidth, υπολογίζει εκ νέου το zoom σε κάθε αλλαγή μεγέθους ώστε η σελίδα να συνεχίζει να χωράει καθώς αλλάζει το παράθυρο. Η παγίδα είναι ότι η απευθείας ανάθεση του Zoom επαναφέρει το FitMode σε pfmNone. Ως προεπιλογή αυτό είναι σωστό: ένας χρήστης που σκόπιμα πληκτρολόγησε 150% δεν θέλει η επόμενη αλλαγή μεγέθους παραθύρου να το πετάξει. Αλλά εκπλήσσει όποιον συνδέει ένα κουμπί zoom-in ως Zoom := Zoom * 1.25 και μετά δεν μπορεί να καταλάβει γιατί το fit-to-width σταμάτησε να ανταποκρίνεται μετά το πρώτο κλικ. Αν η γραμμή εργαλείων σας προσφέρει και ρητό zoom και λειτουργίες προσαρμογής, πρέπει να θυμάστε εσείς οι ίδιοι την τελευταία επιλογή προσαρμογής του χρήστη και να την αναθέτετε ξανά όταν πατήσουν ξανά το κουμπί προσαρμογής. Το component δεν θα επαναφέρει μια λειτουργία που μια ανάθεση zoom μόλις καθάρισε, και δεν προορίζεται να το κάνει

Ένας προϋπολογισμός μνήμης που μπορείτε να υπερασπιστείτε

Ένας προϋπολογισμός που μπορείτε να γράψετε είναι ένας προϋπολογισμός για τον οποίο μπορείτε να επιχειρηματολογήσετε σε ένα code review, οπότε ξεκινήστε από ένα συγκεκριμένο σενάριο. Ας πούμε ότι η συνεχής κύλιση κρατά την ορατή σελίδα συν μία prefetched σελίδα πάνω και κάτω, μαζί με μια λωρίδα μικρογραφιών. Στο 100% σε μια οθόνη 96-DPI αυτά τα τρία bitmap πλήρους μεγέθους έρχονται σε περίπου 3,5 MB το καθένα, που δεν είναι τίποτα. Στο 300% σε μια οθόνη 4K τα ίδια τρία bitmap είναι περίπου 30 MB το καθένα, και αυτό πριν η cache έχει κρατήσει έστω και μία ιστορική σελίδα. Η αύξηση βρίσκεται στη χειρονομία, όχι στο έγγραφο

Αριθμητική μνήμης bitmap PDFium για viewer Delphi όπου κάθε διπλασιασμός zoom τετραπλασιάζει τη μνήμη σελίδας, η συνεχής κύλιση κρατά τρεις σελίδες ζωντανές, ένας οριοθετημένος προϋπολογισμός LRU υπερασπίζει την cache, και το RenderTile χειρίζεται υπερμεγέθη σχέδια
Κάθε διπλασιασμός zoom τετραπλασιάζει μνήμη bitmap, οπότε η cache χρειάζεται σκληρό πλαφόν και tiles για υπερμεγέθεις σελίδες

Μια λογική προεπιλογή για μια 32-bit διεργασία Delphi είναι ένας προϋπολογισμός bitmap 256 MB κάτω από εξορία LRU. Σε 64-bit μπορείτε να κλιμακώσετε με τη φυσική RAM, αλλά κρατήστε ένα σκληρό ανώτατο όριο ούτως ή άλλως, επειδή η αστοχία από την οποία προφυλάσσεστε δεν είναι η κατάρρευση της διεργασίας σας. Είναι ολόκληρο το μηχάνημα να χτυπιέται με το αρχείο σελιδοποίησής του ενώ ο viewer σας τεχνικά συνεχίζει να τρέχει και ο χρήστης αναρωτιέται γιατί όλα τα υπόλοιπα επιβραδύνθηκαν. Ένα σκληρό όριο αποτυγχάνει προβλέψιμα· μια απεριόριστη cache αποτυγχάνει παίρνοντας μαζί της την επιφάνεια εργασίας. Οι μικρογραφίες αξίζουν τη δική τους μεταχείριση: αποδώστε την καθεμία μία φορά στο μικρό στοχευμένο της μέγεθος και κρατήστε την σε μια ξεχωριστή δεξαμενή που η λογική LRU ποτέ δεν αγγίζει. Η αναπαραγωγή μιας μικρογραφίας 120 pixel με υποδειγματοληψία ενός bitmap πλήρους σελίδας 60 MB είναι ο πιο σπάταλος δυνατός τρόπος για να παραχθεί ένα γραμματόσημο

Μερικές μεμονωμένες σελίδες νικούν κάθε προϋπολογισμό. Ένα μηχανολογικό σχέδιο μεγέθους E ή ένας μεγάλος χάρτης αποδοσμένος ολόκληρος στο 400% είναι μια δέσμευση πολλών εκατοντάδων megabyte, και καμία πολιτική εξορίας δεν το κάνει αποδεκτό. Η απάντηση εκεί είναι να σταματήσετε να αποδίδετε ολόκληρες σελίδες. Το RenderTile ραστεροποιεί μόνο την περιοχή στο pixel offset (Left, Top) μέσα σε μια σελίδα εννοιολογικά κλιμακωμένη σε PageWidth επί PageHeight, οπότε αποδίδετε μόνο το ορατό ορθογώνιο συν ένα περιθώριο ενός tile γύρω του για ομαλή μετακίνηση, και ενσωματώνετε τα offset του tile στο κλειδί της cache μαζί με το zoom. Κρατήστε τις διαστάσεις tile σταθερές σε όλο το έγγραφο. Σταθερά tile σημαίνουν ότι μια αλλαγή DPI ακυρώνει ολόκληρο το πλέγμα καθαρά, ενώ μεταβλητά tile σας αφήνουν να κυνηγάτε ορατές ραφές ανάμεσα σε περιοχές αποδοσμένες σε ελαφρώς διαφορετικές κλίμακες

Δύο γειτονικά χαρακτηριστικά προσθέτουν σιωπηλά σε όλο αυτό. Τα περάσματα φίλτρου χρώματος όπως το grayscale ή η αντιστροφή τρέχουν μετά την απόδοση και παράγουν ένα δεύτερο bitmap πλήρους μεγέθους κάθε φορά, διπλασιάζοντας το αποτύπωμα ανά σελίδα κάθε προβολής που τα χρησιμοποιεί· αυτό το κόστος είναι το θέμα του φιλτραρίσματος χρώματος χαμηλής όρασης για viewer PDF Delphi. Και ένας viewer που επισημαίνει λέξεις κατά τη μετατροπή κειμένου σε ομιλία ακυρώνει την αποδοσμένη προβολή σε κάθε εκφωνούμενη λέξη, οπότε η αλληλεπίδραση μεταξύ επανασχεδιασμών επισήμανσης και ρυθμού ομιλίας έχει μεγαλύτερη σημασία από ό,τι φαίνεται αρχικά, όπως καλύπτεται στο επισήμανση TTS λέξη-προς-λέξη

Οι υπερφορτώσεις απόδοσης, οι προοδευτικοί κωδικοί κατάστασης, και το ίδιο το component viewer τεκμηριώνονται στη σελίδα προϊόντος για το PDFium Component