مقال تقني

تصيير PDF في الخلفية في Delphi باستخدام Futures القابلة للإلغاء

تصيير صفحة في PDFium متزامن (synchronous). أنت تستدعي المكتبة، فتقوم بالتنقيط (rasterises) إلى صورة نقطية قمت بتسليمها إياها، ويعود التحكم عند كتابة البكسل. بالنسبة لصفحة واحدة بحجم الشاشة عند مستوى تكبير واحد يستغرق الأمر بضعة أجزاء من الألف من الثانية ولا يلاحظ أحد. لتصدير 300 نقطة في البوصة (dpi) لمستند مكون من 200 صفحة، أو شريط صور مصغرة يجب أن ينقط كل صفحة في وقت واحد، يكلف نفس الاستدعاء ثوانٍ. إذا قمت بإجراء هذا الاستدعاء من سلسلة الرسائل الرئيسية (main thread)، فستتوقف حلقة الرسائل، وتتوقف النافذة عن إعادة الطلاء، ويرسم Windows عبارة "لا يستجيب" (Not Responding) المخيفة فوق شريط العنوان الخاص بك. العمل صحيح. المكان الذي قمت بتشغيله فيه خاطئ

الإصلاح هو نقل التصيير الطويل إلى سلسلة رسائل خلفية وإعادة النتيجة إلى سلسلة الرسائل الرئيسية، حيث يمكن تسليم الصورة النقطية لعنصر تحكم. لا يمنعك PDFium نفسه من القيام بذلك، ولكن يجب أن يجعل الربط التسليم آمنًا، لأن سطح الخطأ حول "التشغيل على عامل (worker)، والرد على واجهة المستخدم (UI)" واسع والإخفاقات متقطعة. توجد وحدة FPdfAsync في PDFiumPas لإعطاء هذا النمط تنفيذًا واحدًا صحيحًا، مع نموذج إلغاء يتناسب مع كيفية تصرف التصيير الطويل فعليًا

شكل العمل

تسيطر ثلاث عمليات على الحالات التي يدوم فيها التصيير أكثر من إطار (frame). يمشي تصيير الدُفعات (Batch rendering) عبر نطاق صفحة وينقط كل صفحة، عادةً إلى القرص. يفعل التصدير متعدد الصفحات الشيء نفسه ولكنه يجمع المخرجات في ملف واحد. تصيير صفحة الخلفية هو ما يفعله العارض عندما يقفز المستخدم إلى صفحة غير موجودة في ذاكرة التخزين المؤقت بعد، لذلك يتم إنتاج الصورة النقطية خارج سلسلة الرسائل وتظهر عندما تكون جاهزة. تشترك العمليات الثلاثة جميعها في نفس القيود. فهي تعمل لفترة طويلة بما يكفي بحيث لا يمكن لسلسلة رسائل واجهة المستخدم استضافتها، وتنتج نتيجة تحتاجها سلسلة رسائل واجهة المستخدم في النهاية، وقد يتخلى عنها المستخدم. يجب أن يؤدي إغلاق المستند أو التمرير متجاوزاً الصفحة أو الضغط على إلغاء إلى إيقاف العمل بدلاً من إجبار المستخدم على انتظار الإخراج الذي لم يعد يريده

القيد الأخير هو الذي يشكل التصميم. التصيير الذي لا يمكن إلغاؤه هو تصيير يبقي المستند مفتوحًا ويحرق وحدة المعالجة المركزية (CPU) بعد أن لم تعد الإجابة مهمة. لذلك يتم بناء الوحدة حول اثنين من الأوليات التي تتكون: مستقبل (future) يحمل النتيجة إلى الخلف، ورمز (token) يحمل طلب الإلغاء إلى الأمام

مستقبل أطلق وانسى

تأخذ TPdfFuture<T>.Run عاملاً، ورداً، ورمز إلغاء اختياري. تبدأ العامل في سلسلة رسائل خلفية، وعندما ينتهي العامل تسلم الرد على سلسلة الرسائل الرئيسية. المعلمة العامة T هي أياً كان ما ينتجه التصيير، وغالباً ما يكون مقبض صورة نقطية أو سجل حالة. يعمل العامل خارج سلسلة الرسائل؛ يعمل الرد حيث يكون من الآمن لمس VCL

class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

الإغفال المتعمد هو أي نوع من Wait (انتظار). لا توجد طريقة لحظر المتصل حتى يكتمل المستقبل، وهذا ليس سهوًا. يُعد استدعاء Wait من سلسلة الرسائل الرئيسية الطريقة الكلاسيكية للوصول إلى طريق مسدود في واجهة المستخدم (deadlock): يحتاج العامل إلى سلسلة الرسائل الرئيسية لتشغيل رده من خلال Synchronize، وتتوقف سلسلة الرسائل الرئيسية داخل Wait، ولا يمكن لأي من الجانبين المضي قدمًا. من خلال رفض تقديم البدائية، يستبعد المستقبل النمط الذي غالبًا ما يهزم الأشخاص الذين يحاولون كتابة هذا بأنفسهم. يجب أن تستخدم التعليمات البرمجية التي تحتاج بصدق إلى حظر TThread عاديًا وتتحمل العواقب. المستقبل هو لحالة "أطلق وانسى"، وهو ما يمثله تصيير الخلفية في الواقع

يتم تغليف النتيجة في TPdfFutureResult<T>، وهو سجل يخبر الرد بأي من الأشياء الثلاثة حدث. يعني IsSuccess أن العامل قد عاد بشكل طبيعي ويحمل Value التصيير. يعني IsCancelled أن الرمز قد انطلق وأن العامل قد انسحب عند نقطة الإلغاء. يعني IsFailure أن العامل أثار استثناء، ويحمل ErrorMessage النص. يفحص الرد الحالة مرة واحدة ويتفرع، بدلاً من التخمين من قيمة حراسة (sentinel) ما إذا كانت الصورة النقطية المرجعة حقيقية

سباق v1.61.0 الذي غيّر تسليم الرد

الجزء الأكثر إفادة في هذه الوحدة هو تغيير من سطر واحد استغرق بعض الوقت لفهمه. خلال الإصدارات المبكرة، سلمت سلسلة رسائل العامل ردها بـ TThread.Queue. تنشر Queue الرد في قائمة انتظار سلسلة الرسائل الرئيسية وتعود فورًا، وهو ما يقرأ بالضبط ما يريده مستقبل "أطلق وانسى". كان ذلك خاطئًا، والسبب يستحق التوضيح لأنه نوع الخلل الذي يجتاز كل اختبار تفكر في كتابته

يتم إنشاء سلسلة رسائل العامل باستخدام FreeOnTerminate := True. وهذا يعني أنه في اللحظة التي يعود فيها Execute، تقوم السلسلة بتفكيك نفسها، وتستدعي TThread.Destroy الدالة RemoveQueuedEvents(Self) كجزء من عملية التنظيف. تمسح RemoveQueuedEvents أي طريقة في قائمة الانتظار يكون هدفها السلسلة التي تحتضر. لذا كان التسلسل: ينتهي العامل، ويضع الرد في قائمة الانتظار ضد نفسه، ويعود Execute، وتدمر السلسلة نفسها، وتحذف RemoveQueuedEvents الرد الذي لم تقم سلسلة الرسائل الرئيسية بتشغيله بعد. النتيجة اختفت ببساطة. والأسوأ من ذلك، في النافذة الضيقة حيث سحبت سلسلة الرسائل الرئيسية الرد الموجود في قائمة الانتظار وبدأت في تشغيله في نفس اللحظة التي يتم فيها تحرير السلسلة، لمس الرد حقول كائن نصف مدمر، وهو استخدام بعد التحرير (use-after-free)

كان الإصلاح في الإصدار v1.61.0 هو تسليم الرد بـ Synchronize بدلاً من Queue. تحظر Synchronize سلسلة رسائل العامل حتى تنتهي سلسلة الرسائل الرئيسية من تشغيل الرد حتى يكتمل. لا يزال العامل على قيد الحياة أثناء تنفيذ رده، لذلك لا يوجد شيء ليتم تحريره من تحته، ولا تعود السلسلة من Execute (وبالتالي لا تبدأ في تدمير نفسها) حتى يتم تسليم الرد. التسليم مضمون، وتم إغلاق نافذة الاستخدام بعد التحرير

procedure TPdfFutureThread<T>.Execute;
begin
  FResult.Status := pfsSuccess;
  FResult.ErrorMessage := '';
  try
    FToken.ThrowIfCancelled;          // already cancelled? skip the worker
    FResult.Value := FWorker(FToken);
  except
    on E: EPdfOperationCancelled do
    begin
      FResult.Status := pfsCancelled;
      FResult.ErrorMessage := E.Message;
    end;
    on E: Exception do
    begin
      FResult.Status := pfsFailure;
      FResult.ErrorMessage := E.Message;
    end;
  end;

  if Assigned(FReply) then
    // Synchronize, not Queue: this thread is FreeOnTerminate, so a queued reply
    // could be dropped by RemoveQueuedEvents before the main thread ran it.
    Synchronize(DispatchReply);
end;

الدرس العام يفوق الإصلاح المحدد. ردود الاتصال غير المتزامنة من نوع "أطلق وانسى" هي أسهل نمط التزامن للحصول عليه بشكل خاطئ بمهارة، لأن المسار السعيد يعمل من المحاولة الأولى ويعيش الخلل في التفاعل بين ترتيب تمزيق السلسلة وقائمة الانتظار. لا يتكاثر عند الطلب. يعتمد ذلك على ما إذا كانت سلسلة الرسائل الرئيسية قد أفرغت قائمة الانتظار قبل أن ينتهي العامل من تدمير نفسه، وهو توقيت يقرره المجدول بشكل مختلف في كل تشغيل. إن الأساس الصحيح مرة واحدة، في الربط، يستحق أكثر بكثير من نفس الكود المعاد اشتقاقه في كل تطبيق يحتاج إلى تصيير في الخلفية

لماذا تعد ردود الاتصال مؤشرات طرق

العامل والرد ليسا طرقًا مجهولة (anonymous methods). وهي أنواع procedure of object، و TPdfFutureWorker<T> و TPdfFutureReply<T>، وهذا الخيار مفروض بواسطة مصفوفة المترجم. يجمع PDFiumPas في Delphi XE5 وما بعده وفي Free Pascal 3.2 في وضع Delphi، و FPC 3.2 في هذا الوضع لا يدعم الطرق المجهولة. سيتم تجميع رد اتصال بالرجوع إلى الإجراء (reference-to-procedure) يلتقط المتغيرات المحلية في Delphi ويفشل في FPC، لذا تستخدم الوحدة القاسم المشترك الأدنى الذي يقبله كلا المترجمين

النتيجة العملية هي المكان الذي تعيش فيه الحالة. تغلق الطريقة المجهولة فوق المتغيرات المحلية؛ بينما لا يفعل مؤشر الطريقة (method pointer) ذلك. لذلك يجب أن تتدلى أي حالة يحتاجها العامل، فهرس الصفحة، والتكبير، ومسار الإخراج، وأي حالة يحتاج الرد إلى تحديثها، التحكم في الصورة الهدف أو ملصق التقدم، من الكائن الذي يتم تمرير طريقته. في العارض يكون هذا الكائن عادةً هو النموذج أو وحدة تحكم التصيير التي يمتلكها. هذا ليس حلاً بديلاً يُفرض على مضض؛ إنه يحافظ على ملكية تلك الحالة صريحة ومرئية في الكائن المستلم بدلاً من إخفائها داخل إغلاق (closure)

الإلغاء التعاوني، وليس القتل الصعب

الإلغاء هنا تعاوني. لا يوجد API يصل إلى سلسلة رسائل العامل وينهيها، لأن إنهاء سلسلة في منتصف التصيير يترك PDFium يحمل أقفالاً وصوراً نقطية مكتوبة جزئياً، وحالة العملية بعد القتل القسري ليست شيئاً يمكنك التفكير فيه. بدلاً من ذلك يتم تسليم العامل رمزاً للقراءة فقط ويتوقع منه فحصه، وتتم كتابة حلقة التصيير للتحقق منه بين الصفحات أو بين المربعات، حيث يكون التوقف نظيفًا

يقدم الرمز ثلاث طرق لمراقبة الإلغاء. IsCancelled هو استطلاع منطقي رخيص لحلقة تريد الاختبار واتخاذ القرار بنفسها. ThrowIfCancelled هو الحالة الشائعة: استدعيه عند نقطة إلغاء طبيعية وإذا تم طلب الإلغاء، فإنه يثير EPdfOperationCancelled، مما يعيد العامل مباشرة إلى المستقبل. تُرفق RegisterCallback إشعارًا بطلقة واحدة ينطلق مرة واحدة عند إلغاء المصدر، وهو مفيد عندما يتم حظر عامل في شيء يمكنه مقاطعته بدلاً من الجلوس في حلقة محكمة

الاستثناء هو حيث تهم حدود السلسلة. عندما يثير العامل EPdfOperationCancelled، يلتقطه المستقبل ويحوله إلى حالة الإلغاء، لذلك يرى الرد IsCancelled وليس فشلاً. لا يتم تنظيم كائن الاستثناء نفسه أبدًا إلى سلسلة الرسائل الرئيسية. إنه يعيش ويموت على سلسلة رسائل العامل؛ يتم نسخ سلسلة رسالته فقط إلى ErrorMessage. إن تنظيم كائن استثناء حي عبر السلاسل سيعني الوصول إلى الذاكرة المملوكة لسلسلة رسائل تنتهي، وهي نفس فئة الخطأ التي يوجد إصلاح Synchronize لمنعها. يعبر رمز الحالة وسلسلة الحدود بشكل نظيف؛ ولن يفعل كائن ذلك

واجهتان، لذلك لا يمكن للعامل أن يلغي نفسه

ينقسم الإلغاء عبر واجهتين عن قصد. IPdfCancellationTokenSource هو جانب الكتابة: يحتوي على Cancel، والمالك الذي ينشئه، وهو النموذج عادةً، يحتفظ به ويستدعي Cancel عندما ينقر المستخدم على الزر أو يُغلق النموذج. IPdfCancellationToken هو جانب القراءة: يحتوي على IsCancelled و ThrowIfCancelled و RegisterCallback، وهذا هو كل ما يتلقاه العامل. ينفذ كائن صلب واحد كليهما، ولكن العامل لا يُسلم إلا الرمز، لذلك ليس لديه طريقة لإلغاء العملية التي يتم تشغيلها. التقسيم هو حاجز حماية على مستوى API. العامل الذي يمكن أن يصل إلى Cancel من خلال الرمز الخاص به من شأنه أن يدعو جزءًا مرتبكًا من الكود لإلغاء نفسه، ويزيل نظام النوع هذا الاحتمال

هناك تفصيل مطابق للحالة التي يريد فيها المتصل التصيير ولكنه لا ينوي إطلاقا إلغاءه. بدلاً من إجبار مصدر جديد لكل مكالمة، تعرض الوحدة PdfNoCancellationToken، وهو رمز مفرد (singleton) في حالة غير ملغاة بشكل دائم. يستبدلها Run عندما تترك وسيطة الرمز (token argument) nil. يتم إنشاء هذا المفرد بفارغ الصبر أثناء تهيئة الوحدة بدلاً من إنشائه بتكاسل عند أول استخدام، والسبب هو التزامن مرة أخرى. إذا وصلت العديد من استدعاءات Run في سلاسل رسائل عمالية مختلفة جميعها إلى مفرد تم إنشاؤه بتكاسل مرة واحدة، فيمكن أن تتسابق على إنشائه، أو تسرب نسخة مكررة، أو تلاحظ باختصار مثيلًا نصف مهيأ. إن بناءه قبل أن يتمكن أي عامل من العمل يزيل السباق تمامًا

تشغيل تصيير قابل للإلغاء

في الممارسة العملية تقوم بإنشاء مصدر، والاحتفاظ به في النموذج، وتمرير Token الخاص به إلى Run جنبا إلى جنب مع طريقة العامل وطريقة الرد، وربط زر الإلغاء بالمصدر. يتحقق العامل من الرمز أثناء التصيير؛ ويحدث الرد واجهة المستخدم بمجرد عودة النتيجة. نظرًا لأن ردود الاتصال هي مؤشرات طرق، يقرأ العامل والرد كل ما يحتاجون إليه من حقول النموذج

procedure TMainForm.StartRender;
begin
  FCancelSource := TPdfCancellationTokenSource.New;  // field, lives on the form
  TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;

procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
  if Assigned(FCancelSource) then
    FCancelSource.Cancel;   // worker observes this at its next cancel point
end;

// Runs on a background thread. Reads FPageRange / FOutputDir from the form.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
  PageIndex: Integer;
begin
  for PageIndex := FFirstPage to FLastPage do
  begin
    AToken.ThrowIfCancelled;        // clean stop between pages
    RenderOnePage(PageIndex);       // synchronous PDFium rasterisation
  end;
  Result := True;
end;

// Runs on the main thread. Safe to touch the VCL here.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
  if AResult.IsSuccess then
    StatusLabel.Caption := 'Render complete'
  else if AResult.IsCancelled then
    StatusLabel.Caption := 'Cancelled'
  else
    StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;

يعالج الرد جميع النتائج الثلاثة لأن الثلاثة جميعًا يمكن الوصول إليها. يبلغ التصيير النهائي عن النجاح، ويرى المستخدم الذي ضغط على إلغاء الفرع الملغى، والملف الذي لا يمكن كتابته أو الصفحة التي فشلت في التحليل تصل كفشل برسالة. لا تحظر أي من تلك الفروع، ولا يلمس أي منها سلسلة رسائل العامل، ولا تتم قراءة الصورة النقطية أو الحالة التي أنتجها العامل إلا بعد أن يسلمها المستقبل في السلسلة التي تمتلك واجهة المستخدم

يؤتي نفس انضباط السلسلة ثماره في أي مكان آخر في العارض. يتم تغطية الطريقة التي يتم بها الاحتفاظ بالصور النقطية المصيرة وإعادة استخدامها عبر تغييرات التكبير في ملاحظتنا على ذاكرة التخزين المؤقت للتصيير وأداء التكبير، والسؤال الأوسع حول الحفاظ على حدود PDFium آمنة في Delphi هو في تصلب ABI لمكون PDFium لسلامة الذاكرة. تُشحن البنية التحتية غير المتزامنة الموضحة هنا كجزء من مكون PDFium لـ Delphi و C++Builder، جنبًا إلى جنب مع API التصيير والنص والنموذج التي تمت تغطيتها في مكان آخر على هذه المدونة