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

Ακυρώσιμη Προοδευτική Απόδοση PDF στη Delphi (PDFium)

Οι περισσότερες σελίδες PDF ραστεροποιούνται σε λίγα χιλιοστά του δευτερολέπτου και ποτέ δεν το σκέφτεστε. Έπειτα ένας χρήστης ανοίγει ένα μηχανολογικό σχέδιο (engineering drawing) Α1, μια σελίδα γεμάτη με δεκάδες χιλιάδες διανυσματικές πινελιές (vector strokes), ή μια αφίσα γεμάτη με ομάδες διαφάνειας (transparency groups) και απαλές μάσκες (soft masks), και η μοναδική κλήση που τη ζωγραφίζει διαρκεί δύο ή τρία δευτερόλεπτα. Εάν αυτή η κλήση εκτελεστεί στο νήμα (thread) του UI, το παράθυρο σταματά να επανασχεδιάζεται, η γραμμή τίτλου γίνεται γκρι, και το λειτουργικό σύστημα προσφέρεται να τερματίσει την εφαρμογή. Η εργασία είναι θεμιτή (legitimate). Η σελίδα πραγματικά χρειάζεται τόσο χρόνο. Το ελάττωμα είναι ότι η απόδοση (render) είναι μια αδιαίρετη κλήση αποκλεισμού (blocking call) χωρίς κανέναν τρόπο να αναπνεύσει (come up for air) και χωρίς κανέναν τρόπο να σταματήσει

Αυτό το άρθρο αφορά ακριβώς ένα από αυτά τα δύο προβλήματα: την ακύρωση μιας χρονοβόρας απόδοσης μίας σελίδας χωρίς πάγωμα του UI. Ο χρήστης έκανε κλικ στην επόμενη σελίδα, ή έκανε ζουμ, ή έκλεισε το έγγραφο, και η απόδοση που βρίσκεται σε εξέλιξη είναι πλέον χαμένη εργασία που θα πρέπει να τελειώσει στην επόμενη ευκαιρία αντί να εκτελεστεί μέχρι την ολοκλήρωση. Η εξομάλυνση της κύλισης (scroll) και του ζουμ μέσω της προσωρινής αποθήκευσης (caching) όσων έχουν ήδη ραστεροποιηθεί είναι ένα ξεχωριστό μέλημα με το δικό του σχεδιασμό, το οποίο καλύπτεται στο συνοδευτικό άρθρο που συνδέεται στο τέλος. Εδώ το μόνο ερώτημα είναι πώς να κάνουμε μια προοδευτική απόδοση να απαντήσει σε ένα αίτημα ακύρωσης γρήγορα και καθαρά

Το API προοδευτικής απόδοσης που ήδη διαθέτει το PDFium

Το PDFium προέβλεψε (anticipated) το μισό πρόβλημα, αυτό του παγώματος. Δίπλα στην εφάπαξ (one-shot) FPDF_RenderPageBitmap, εκθέτει (exposes) μια προοδευτική παραλλαγή (progressive variant) που χωρίζει μια σελίδα σε κομμάτια (chunks) εργασίας. Καλείτε την FPDF_RenderPageBitmap_Start μία φορά για να ρυθμίσετε την απόδοση σε σχέση με ένα bitmap προορισμού, και στη συνέχεια καλείτε την FPDF_RenderPage_Continue επανειλημμένα. Κάθε Continue ραστεροποιεί ένα οριοθετημένο τμήμα (slice) και επιστρέφει μια κατάσταση (status). Το FPDF_RENDER_TOBECONTINUED σημαίνει ότι υπάρχουν περισσότερα να γίνουν, το FPDF_RENDER_DONE σημαίνει ότι η σελίδα έχει τελειώσει, και το FPDF_RENDER_FAILED σημαίνει ότι σταμάτησε λόγω σφάλματος. Όταν τελειώσει ο βρόχος (loop), καλείτε την FPDF_RenderPage_Close για να απελευθερώσετε την προοδευτική κατάσταση (progressive state) ανά σελίδα. Επειδή ο έλεγχος επιστρέφει στον κώδικά σας μεταξύ των τμημάτων, μπορείτε να αντλήσετε (pump) μηνύματα, να ενημερώσετε έναν δείκτη προόδου (progress indicator), ή να ελέγξετε αν η εργασία είναι ακόμα επιθυμητή

Ο μηχανισμός που παρέχει το PDFium για να αποφασίσετε πότε να παραχωρήσετε (yield) τον έλεγχο είναι μια δομή (struct) ανάκλησης (callback) με το όνομα IFSDK_PAUSE. Την παραδίδετε στη Start και σε κάθε Continue. Μετά από κάθε κομμάτι, το PDFium καλεί τον δείκτη συνάρτησής (function pointer) της NeedToPauseNow, και αν αυτό επιστρέψει μια μη μηδενική τιμή, το τρέχον Continue σταματά νωρίς και επιστρέφει τον έλεγχο με FPDF_RENDER_TOBECONTINUED. Η δομή φέρει επίσης ένα πεδίο version, το οποίο πρέπει να οριστεί σε 1, και έναν δείκτη ελεύθερης μορφής user τον οποίο το PDFium δεν αγγίζει ποτέ και τον περνάει (passes through) ανέπαφο. Αυτός ο ανέπαφος δείκτης είναι ολόκληρος ο μεντεσές (hinge) του σχεδιασμού που ακολουθεί

Επαναπροσδιορισμός (repurposing) της παύσης ως ακύρωσης

Η αρχική πρόθεση της NeedToPauseNow είναι ο χρονικός καταμερισμός (time-slicing). Επιστρέψτε μη μηδενική τιμή όταν ο προϋπολογισμός καρέ (frame budget) σας εξαντληθεί, επιστρέψτε μηδέν για να συνεχίσετε την απόδοση, και το PDFium κάνει παύση ώστε να μπορείτε να κάνετε κάτι άλλο πριν συνεχίσετε την ίδια απόδοση. Το στοιχείο (Component) PDFium επαναχρησιμοποιεί το ίδιο σήμα για ένα διαφορετικό ρήμα. Αντί να απαντήσει "πρέπει να κάνω παύση και να σε αφήσω να συνεχίσεις", η ανάκληση απαντά "έχει ακυρωθεί αυτή η εργασία;". Τα δύο αντιστοιχίζονται (map) το ένα στο άλλο καθαρά λόγω του τι κάνει ο βρόχος (loop) όταν βλέπει τη σημαία (flag). Μια γνήσια παύση περιμένει ένα μεταγενέστερο Continue· μια ακύρωση όχι. Μόλις ο καλών βρόχος παρατηρήσει ότι το κουπόνι (token) έχει ακυρωθεί, κλείνει το πλαίσιο απόδοσης (render context) και δεν καλεί ποτέ ξανά το Continue, επομένως η ίδια μη μηδενική επιστροφή που το PDFium διαβάζει ως "σταμάτα αυτό το κομμάτι" γίνεται, στην ουσία (in effect), "σταμάτα για πάντα" (stop for good)

Η ακύρωση εκφράζεται μέσω μιας διεπαφής (interface), του IPdfCancellationToken, του οποίου η ιδιότητα IsCancelled αλλάζει (flips) από ψευδής σε αληθής όταν κάποιο άλλο μέρος του προγράμματος ζητά να σταματήσει η απόδοση. Η γέφυρα μεταξύ αυτής της διεπαφής Pascal και της C ανάκλησης του PDFium είναι ένας μόνο δείκτης (pointer). Η αναφορά (reference) διεπαφής του κουπονιού γράφεται στο IFSDK_PAUSE.user, και μια στατική ανάκληση cdecl τη διαβάζει και την ερωτά (queries). Αυτό είναι το κλασικό πρόβλημα του να αφήνετε μια βιβλιοθήκη C να καλεί πίσω σε Pascal: η ανάκληση πρέπει να είναι μια απλή συνάρτηση (plain function) με σύμβαση κλήσης (calling convention) C, όχι μια μέθοδος, επειδή το PDFium αποθηκεύει και καλεί (invokes) έναν γυμνό (bare) δείκτη συνάρτησης που δεν γνωρίζει τίποτα για αντικείμενα Pascal ή για το Self

type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFium reads this; .user holds the token
    Token: IPdfCancellationToken; // strong ref keeps the token alive
  end;

function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
  Token: IPdfCancellationToken;
begin
  Result := 0;
  if (pThis = nil) or (pThis^.user = nil) then
    Exit;
  Token := IPdfCancellationToken(pThis^.user);
  if Token.IsCancelled then
    Result := 1; // non-zero: PDFium stops this chunk
end;

Η ανάκληση ανακτά το κουπόνι (token) μετατρέποντας (casting) το pThis^.user πίσω στον τύπο της διεπαφής και διαβάζει το IsCancelled. Τίποτα μέσα σε αυτήν δεν εκχωρεί (allocates), δεν κλειδώνει (locks) ή δεν αποκλείει (blocks), πράγμα που έχει σημασία επειδή το PDFium την καλεί στο νήμα απόδοσης (rendering thread) μετά από κάθε κομμάτι (chunk) και οποιαδήποτε εργασία γίνεται εδώ προστίθεται στο κόστος της ίδιας της απόδοσης. Η προστασία (guard) έναντι μιας μηδενικής (nil) δομής ή ενός μηδενικού πεδίου user σημαίνει ότι η ίδια συνάρτηση είναι ασφαλής για εγκατάσταση ακόμα και σε μια απόδοση στην οποία δεν δόθηκε ποτέ ένα πραγματικό κουπόνι

Διατήρηση του κουπονιού ζωντανού σε όλη τη διάρκεια του βρόχου

Η μετατροπή (casting) ενός δείκτη (pointer) διεπαφής μέσω ενός ακατέργαστου (raw) Pointer και αντίστροφα είναι το σημείο όπου γεννιούνται τα σφάλματα διάρκειας ζωής (lifetime bugs). Μια IInterface στη Delphi μετράει αναφορές (reference counted), και η μέτρηση κινείται μόνο όταν ο μεταγλωττιστής (compiler) μπορεί να δει ότι εκχωρείται μια μεταβλητή τύπου διεπαφής. Η αποθήκευση του κουπονιού (token) αποκλειστικά ως ένας γυμνός (bare) δείκτης μέσα στο IFSDK_PAUSE.user θα το έκρυβε εντελώς από τον μετρητή αναφορών. Εάν η μόνη άλλη αναφορά σε αυτό το κουπόνι έβγαινε εκτός εμβέλειας (out of scope) ενώ ο βρόχος Continue εξακολουθούσε να εκτελείται, το αντικείμενο θα απελευθερωνόταν κάτω από την ανάκληση (callback), και το επόμενο κομμάτι (chunk) θα αποαναφοροποιούσε (dereference) έναν δείκτη που αιωρείται (dangling pointer)

Γι' αυτό ο περιγραφέας (descriptor) είναι μια εγγραφή (record) που κρατάει δύο πράγματα, όχι ένα. Το πεδίο Pause είναι η δομή (struct) που διαβάζει το PDFium. Το πεδίο Token είναι μια πραγματική αναφορά (reference) τύπου διεπαφής που μετράει ο μεταγλωττιστής, και υπάρχει για κανέναν άλλο λόγο παρά για να καρφιτσώσει (pin) το κουπόνι στη μνήμη για όσο διάστημα ζει η εγγραφή. Η εγγραφή είναι μια τοπική μεταβλητή (local variable) στη στοίβα (stack) της ρουτίνας απόδοσης, οπότε παραμένει έγκυρη (valid) για όλη τη διάρκεια του βρόχου (loop) και αποδομείται (torn down) μόνο όταν η ρουτίνα εξέρχεται. Ο γυμνός δείκτης στο user και η μετρημένη αναφορά στο Token ονομάζουν το ίδιο αντικείμενο· το ένα είναι αυτό που μπορεί να διαβάσει το PDFium, το άλλο είναι αυτό που εμποδίζει το αντικείμενο από το να συλλεχθεί (collected)

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... choose EffectiveToken ...

  // Strong ref first, then publish the same object to PDFium via .user.
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

Κλείσιμο του πλαισίου απόδοσης (render context) ανεξάρτητα από το πώς τελειώνει ο βρόχος

Κάθε κλήση στην FPDF_RenderPageBitmap_Start εκχωρεί προοδευτική κατάσταση (progressive state) που το PDFium συσχετίζει με τη σελίδα, και αυτή η κατάσταση απελευθερώνεται μόνο από την FPDF_RenderPage_Close. Υπάρχουν τρεις τρόποι εξόδου από τον βρόχο οδήγησης (drive loop). Η σελίδα τελειώνει και η τελευταία κατάσταση είναι FPDF_RENDER_DONE. Το κουπόνι (token) ενεργοποιείται (trips) και ο βρόχος εξέρχεται νωρίς αναφέροντας ακύρωση. Κάτι αποτυγχάνει και η κατάσταση είναι FPDF_RENDER_FAILED. Και οι τρεις πρέπει να καλέσουν την Close, και η διαδρομή ακύρωσης (cancellation path) είναι η πιο εύκολη να γίνει λάθος, επειδή το φυσικό σχήμα του "βλέπω ακύρωση, βγαίνω έξω" τείνει να παρακάμπτει (skip) τον καθαρισμό (cleanup) στον δρόμο του προς την έξοδο. Το να αφήσετε την Close απλησίαστη (unreached) διαρρέει (leaks) την κατάσταση ανά σελίδα (per-page state), και ένα πρόγραμμα προβολής (viewer) που επιτρέπει στον χρήστη να ακυρώνει την απόδοση τη μία μετά την άλλη θα συσσώρευε αυτή τη διαρροή σε κάθε ματαιωμένη (aborted) σελίδα

Το ισχυρό (robust) σχήμα τοποθετεί τον βρόχο και την ταξινόμηση (classification) του αποτελέσματος μέσα σε ένα try και την FPDF_RenderPage_Close στο αντίστοιχο finally. Το bitmap προορισμού καταστρέφεται στο ίδιο μπλοκ (block). Η ακύρωση μπορεί να εγκαταλείψει τον βρόχο μέσω ενός πρώιμου (early) Exit και το finally εξακολουθεί να εκτελείται, οπότε υπάρχει ακριβώς ένα μέρος που απελευθερώνει (frees) την προοδευτική κατάσταση και δεν μπορεί να παρακαμφθεί (bypassed)

Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
  Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
  while Status = FPDF_RENDER_TOBECONTINUED do
  begin
    if EffectiveToken.IsCancelled then
    begin
      Result := prsCancelled;
      Exit;
    end;
    Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
  end;

  if EffectiveToken.IsCancelled then
    Result := prsCancelled
  else if Status = FPDF_RENDER_DONE then
    Result := prsDone
  else
    Result := prsFailed;
finally
  // Frees the progressive state Start allocated; mandatory on every path.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

Ο βρόχος ελέγχει το κουπόνι πριν από κάθε Continue καθώς και βασίζεται στην ανάκληση (callback) μέσα σε αυτό. Η ανάκληση συντομεύει (shortens) το τρέχον κομμάτι (chunk)· ο έλεγχος του βρόχου εμποδίζει την έναρξη του επόμενου. Μαζί οριοθετούν (bound) τον χρόνο που χρειάζεται μια ακύρωση για να τεθεί σε ισχύ σε περίπου τη διάρκεια ενός κομματιού

Τρία αποτελέσματα (outcomes), και τι περιέχει το bitmap μετά από μια ακύρωση

Το δημόσιο σημείο εισόδου είναι η TPdf.RenderPageProgressive, και επιστρέφει ένα TPdfProgressiveStatus που είναι ένα από τα prsDone, prsCancelled, ή prsFailed. Οι τιμές αντικατοπτρίζουν τις σταθερές (constants) FPDF_RENDER_* του PDFium στο ιδίωμα (idiom) της Pascal, αλλά ενσωματώνουν (fold in) την περίπτωση ακύρωσης ως ένα αποτέλεσμα πρώτης τάξης (first-class result) αντί για ένα σφάλμα (error)

Το σημείο που μπερδεύει (catches) τους ανθρώπους είναι το τι περιέχει το bitmap προορισμού μετά το prsCancelled. Δεν είναι κενό (blank). Το PDFium αποδίδει (renders) προοδευτικά στο ίδιο bitmap κομμάτι (chunk) με το κομμάτι, επομένως όταν μια ακύρωση σταματά τον βρόχο, το bitmap κρατάει οτιδήποτε είχε ζωγραφιστεί μέχρι εκείνη τη στιγμή, το οποίο είναι μια μερική (partial) εικόνα: κάποιες ζώνες (bands) έχουν ολοκληρωθεί, ενώ οι υπόλοιπες εξακολουθούν να δείχνουν το χρώμα γεμίσματος (fill colour). Το αν αυτό το μερικό αποτέλεσμα είναι χρήσιμο εξαρτάται από τον καλούντα. Ένα πρόγραμμα προβολής που πρόκειται να πετάξει το bitmap επειδή ο χρήστης περιηγήθηκε (navigated) αλλού μπορεί απλώς να το αγνοήσει. Ένα πρόγραμμα προβολής που θέλει να δείξει μια προεπισκόπηση (preview) χαμηλού κόστους μπορεί να το κρατήσει. Αυτό που δεν πρέπει να κάνετε είναι να υποθέσετε ότι το prsCancelled συνεπάγεται (implies) ένα κενό ή ακαθόριστο (undefined) bitmap· συνεπάγεται ένα αληθινό στιγμιότυπο (truthful snapshot) μιας ημιτελούς (unfinished) απόδοσης

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // Token starts un-cancelled; flip Token.IsCancelled from elsewhere
    // (a UI action, a navigation event) to abort the render in flight.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // fully rendered
      prsCancelled: ;                            // partial bitmap, usually discarded
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

Το μηδενικό (nil) κουπόνι (token) και μια διαδρομή ανάκλησης (callback) χωρίς διακλαδώσεις (branch-free)

Η ακύρωση είναι προαιρετική (opt-in). Ένας καλών που απλώς θέλει προοδευτική απόδοση για το όφελος της άντλησης μηνυμάτων (message-pumping), χωρίς πρόθεση ματαίωσης (aborting), θα πρέπει να είναι σε θέση να περάσει nil για το κουπόνι. Ο απλοϊκός (naive) τρόπος υποστήριξης αυτού είναι η διασπορά (scatter) ελέγχων "εάν δόθηκε κουπόνι" (if a token was supplied) στην ανάκληση και στον βρόχο, πράγμα που σημαίνει μια διακλάδωση (branch) σε κάθε κομμάτι και μια ανάκληση που πρέπει να χειριστεί τόσο ένα πραγματικό κουπόνι όσο και την απουσία του

Η υλοποίηση το αποφεύγει αυτό αντικαθιστώντας (substituting) ένα singleton (μοναδικό στιγμιότυπο) όταν ο καλών δεν περνάει τίποτα. Ένα κουπόνι nil ανταλλάσσεται (swapped) με το PdfNoCancellationToken, μια διεπαφή της οποίας το IsCancelled είναι πάντα ψευδές (false). Από αυτό το σημείο η ανάκληση και ο βρόχος έχουν ένα κουπόνι (token) για να υποβάλουν ερώτημα (query) σε κάθε περίπτωση, οπότε κανένα από τα δύο δεν χρειάζεται έναν έλεγχο nil και κανένα από τα δύο δεν χρειάζεται μια ειδική διαδρομή. Το κουπόνι ποτέ-ακύρωσης (never-cancel token) απλώς απαντά πάντα ψευδές, η ανάκληση επιστρέφει πάντα μηδέν, και η απόδοση εκτελείται μέχρι την ολοκλήρωση (runs to completion) ακριβώς όπως θα έκανε μια μη ακυρώσιμη. Η προαιρετική συμπεριφορά (optional behaviour) μοντελοποιείται ως ένα κουπόνι που δεν ενεργοποιείται ποτέ (never fires) αντί ως η απουσία ενός κουπονιού, γεγονός που διατηρεί την καυτή διαδρομή (hot path) ομοιόμορφη (uniform)

// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

Το σχήμα που προκύπτει (emerges) είναι μικρό και αξίζει να επαναδιατυπωθεί (restating), επειδή είναι το επαναχρησιμοποιήσιμο τμήμα. Μια βιβλιοθήκη C που υποστηρίζει μια ανάκληση (callback) σας δίνει ακριβώς ένα κανάλι (channel) για να περάσετε την κατάσταση (state) μέσα σε αυτήν την ανάκληση, τον αδιαφανή (opaque) δείκτη χρήστη (user pointer). Τοποθετήστε μια μετρημένη (counted) αναφορά (reference) διεπαφής Pascal πίσω από αυτόν τον δείκτη, διατηρήστε μια δεύτερη πραγματική αναφορά ζωντανή δίπλα στη δομή (struct) ώστε το αντικείμενο να μην μπορεί να συλλεχθεί στη μέση της κλήσης (mid-call), και διαβάστε τη διεπαφή πάλι πίσω μέσα σε μια στατική (static) συνάρτηση cdecl. Τυλίξτε (wrap) ολόκληρο τον βρόχο οδήγησης (drive loop) σε ένα try και απελευθερώστε το εγγενές (native) πλαίσιο (context) στο finally. Το ίδιο πρότυπο (template) μεταφέρεται σε οποιαδήποτε προοδευτική (progressive) ή βασισμένη-σε-ανάκληση (callback-driven) λειτουργία του PDFium, όπου ο κώδικας Pascal πρέπει να παραμείνει στον έλεγχο της διάρκειας ζωής (lifetime) ενώ η C κρατάει έναν δείκτη

Η ακύρωση είναι μόνο το ήμισυ (one half) ενός προγράμματος προβολής που ανταποκρίνεται (responsive). Το άλλο μισό είναι να μην αποδίδετε εκ νέου (re-rendering) σελίδες που έχετε ήδη σχεδιάσει (drew), και να διατηρείτε το ζουμ και την κύλιση (scroll) ομαλά (smooth) παρέχοντας αποθηκευμένα στην κρυφή μνήμη (cached) bitmaps, το οποίο καλύπτεται στο άρθρο μας σχετικά με την κρυφή μνήμη απόδοσης (render caching) και την απόδοση του ζουμ. Για το πώς η ακυρώσιμη απόδοση ταιριάζει (fits) σε ένα πλήρες πρόγραμμα προβολής μαζί με την πλοήγηση (navigation), την επιλογή και την αναζήτηση, ανατρέξτε (see) στη δημιουργία ενός πλούσιου σε δυνατότητες προγράμματος προβολής PDF με το στοιχείο PDFium VCL. Η προοδευτική απόδοση που περιγράφεται εδώ αποστέλλεται (ships) ως μέρος του στοιχείου PDFium για Delphi και Lazarus, μαζί με τα API φόρτωσης (loading), απόδοσης και φορμών που καλύπτονται αλλού σε αυτό το ιστολόγιο