Tekninen artikkeli

OnExit-uudelleenajautuvuus Delphi-paikallisessa lomake-editorissa

PDFlibPas:n TPDFlibViewer-ohjausobjekti sitoo paikallisen lomakekenttäeditorin editorin oman OnExit-tapahtuman kautta, ja tuo suunnitteluvalinta piilottaa klassisen Delphi VCL -ansan: fokusoidun ohjausobjektin piilottaminen, uudelleenvanhemmoiminen tai tuhoaminen sen oman OnExit-käsittelijän sisältä voi laukaista OnExit:n toisen kerran ennen kuin ensimmäinen kutsu palautuu, lähettäen sitomislogiikan takaisin itseensä

Tämän tuottama epäonnistuminen vastustaa puhdasta toistamista. Käyttäjä tabuloi nopeasti läpi rivin tekstikenttiä skannatussa hakemuslomakkeessa, ja silloin tällöin katseluohjelma nostaa access violationin, tai pahempaa, jatkaa toimintaansa hiljaa kirjoittaen väärän arvon kenttään kaksi tabia taaksepäin. Toista se pyynnöstä, ja bugi näyttää jälkikäteen ilmeiseltä; jäljitä se yhden asiakkaan kaatumisraportista, ja se näyttää haamulta, koska laukeaako toinen OnExit todella, riippuu ikkunakahva- ja fokusajoituksesta, joka vaihtelee kenttätyypin, kirjoitusnopeuden ja minkä tahansa muun mukaan, mitä viestijono tekee tuolla hetkellä

Miten TPDFlibViewer asettaa todellisen editorin renderöidyn sivun päälle

TPDFlibViewer renderöi jokaisen PDF-sivun bittikartaksi eikä muuta lomakekenttiä eläviksi VCL-ohjausobjekteiksi oletuksena, joten BeginEditFormField on metodi, joka siltaa nämä kaksi maailmaa: kutsuttuna kenttäindeksillä se hakee kentän suorakulmion ja muuntaa sen asiakaskoordinaateiksi, ja pudottaa sitten todellisen TEdit- tai TMemo-olion tuon suorakulmion päälle tekstikentälle, tai TComboBox-olion csDropDownList-tyylissä valintakentälle, kentän nykyisen arvon jo ladattuna. ISO 32000-2 §12.7 määrittelee, mikä teksti- tai valintalomakekenttä on PDF:n sisällä, mutta mikään tuossa spesifikaatiossa ei sano, miten Windows-sovelluksen pitäisi antaa jonkun kirjoittaa sellaiseen, ja tuo aukko on juuri se, mitä varten BeginEditFormField on olemassa täyttämään. Sekä OnKeyDown että OnExit on kytketty samoihin kahteen katseluohjelmametodiin, InplaceEditorKeyDown ja InplaceEditorExit, jokaisessa editorissa, jonka TPDFlibViewer luo, pariutus, joka on toimitettu muuttumattomana siitä lähtien, kun interaktiivinen lomakkeen täyttö ensimmäisen kerran laskeutui v3.220.0:ssa, ja OnExit on se, mistä hankaluus alkaa

Miksi editorin piilottaminen laukaisee OnExit:n toisen kerran?

VCL:n TWinControl kohtelee Visible- tai Parent-ominaisuuden muutosta fokusoidulla ohjausobjektilla syynä siirtää fokus siitä pois välittömästi, ja fokuksen siirtäminen pois ohjausobjektista on juuri se, mikä laukaisee tuon ohjausobjektin OnExit-tapahtuman, synkronisesti, ennen kuin ominaisuudensijoitus, joka sen laukaisi, edes palautuu. CommitInplaceEditor, metodi, jota PDFlibPas käyttää sulkeakseen paikallisen editorin ja kirjoittaakseen sen arvon takaisin lomakekenttään, tarvitsee tehdä juuri nuo kaksi asiaa poistuessaan: asettaa Editor.Visible false:ksi ja Editor.Parent nilliksi, jotta ohjausobjekti lakkaa piirtymästä sivun päälle ja lakkaa vastaanottamasta syötettä. Tee kumpi tahansa niistä, kun editorilla on yhä fokus, mikä sillä lähes aina on, koska käyttäjä juuri poistui siitä, ja OnExit laukeaa uudelleen keskellä sitä samaa kutsua, jonka piti olla viimeinen asia, jonka tuon editorin OnExit koskaan laukaisi

Mikä menee pieleen, kun CommitInplaceEditor ajautuu itseensä uudelleen?

Naiivi sitomismetodi maksaa tästä yhdellä kahdesta tavasta. Joko se kirjoittaa kentän arvon kahdesti, kerran alkuperäisestä kutsusta ja kerran uudelleenajautuvasta kutsusta, joka livahti sisään ennen kuin ensimmäinen ehti koskea omaa tilaansa, tai se yrittää vapauttaa editoriohjausobjektin, samalla kun kutsupinossa alempana oleva kehys on yhä saman ohjausobjektin oman tapahtumajakelun sisällä, mikä on määrittelemätöntä aluetta VCL:ssä ja näkyy access violationina, joka voi osoittaa lähes mihin tahansa riviin, ei välttämättä siihen, joka todella aiheutti sen. Kumpikaan epäonnistuminen ei tarvitse suurta lomaketta laukaistakseen; kahden kentän asiakirja riittää, kunhan käyttäjä poistuu toisesta kentästä tarpeeksi nopeasti, että käyttöjärjestelmä yhä purkaa fokusviestejä ensimmäisestä

// Naive version: reads fine in review, fails only under real typing speed
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // still running inside FEditor's own OnExit
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // focused control reparented here: OnExit
                               // fires again, re-entering this same method
  FEditor.Free;                // freed while a caller further down the
  FEditor := nil;              // stack is still inside its OnExit handler
end;

Nillaa viittaus ennen kuin kosketat ohjausobjektia

Korjaus, jonka PDFlibPas toimittaa, on yksi uudelleenjärjestely: vangitse editori paikalliseen muuttujaan, tyhjennä kenttä, joka osoittaa siihen, ja vasta sitten aloita ohjausobjektin ominaisuuksien muuttaminen. CommitInplaceEditor lukee FInplaceEditor:n paikalliseen Editor-muuttujaan, asettaa FInplaceEditor:n heti nilliksi, ja vasta sen jälkeen asettaa Editor.Visible- ja Editor.Parent-arvot. Uudelleenajautuva kutsu, jonka jompikumpi noista kahdesta sijoituksesta laukaisee, lukee itse FInplaceEditor:n, löytää sen jo nilliksi, ja poistuu ensimmäisellä rivillään, ennen kuin se voi koskea Editor:iin tai kirjoittaa kentän arvon toisen kerran

procedure TPDFlibViewer.CommitInplaceEditor(Save: Boolean);
var
  Editor: TWinControl;
begin
  Editor := FInplaceEditor;
  if not Assigned(Editor) then
    Exit;                      // a reentrant call lands here and stops
  FInplaceEditor := nil;       // detach before the control is touched at all
  if Save then
    SaveEditorValue(Editor);   // safe: FInplaceEditor is already nil
  Editor.Visible := False;
  Editor.Parent := nil;        // may fire OnExit again; the guard above
                                // turns that reentrant call into a no-op
  ReapDeadEditor;               // free whatever was parked last cycle
  FDeadEditor := Editor;        // park this one instead of freeing it here
end;

SaveEditorValue tuossa listauksessa edustaa todellista haaraa, joka tarkistaa, onko Editor TComboBox, TMemo vai TEdit, ja lukee sen arvon vastaavasti, koska PDFlibPas luo eri ohjausobjektin riippuen siitä, onko kenttä tekstikenttä vai valintakenttä. Vartija ei välitä, mikä haara ajetaan, vain siitä, että FInplaceEditor on nil ennen kuin mikään, joka voi laukaista OnExit:n, suoritetaan, mikä on se yksi järjestysrajoite, joka tekee metodin lopun turvalliseksi kirjoittaa millä tahansa muuten luonnollisella tyylillä

Älä koskaan vapauta ohjausobjektia sen omasta tapahtumasta

TPDFlibViewer.CommitInplaceEditor ei koskaan kutsu Editor.Free:tä suoraan, ja se on tarkoituksellista: ohjausobjektin vapauttaminen on turvatonta, kun pinokehys, joka kuuluu saman ohjausobjektin omaan tapahtumajakeluun, saattaa yhä purkautua sen kutsun yläpuolella, joka vapauttaa sen, uudelleenajautuva OnExit tai ei. PDFlibPas antaa sen sijaan irrotetun editorin yhden paikan pysäköintipaikkaan, FDeadEditor, vapauttaen mitä tahansa siellä istui edellisestä muokkauskierroksesta pienen apufunktion, ReapDeadEditor, kautta, kutsuttuna seuraavan BeginEditFormField:n alussa ja kerran vielä CloseDocument:sta; jokainen editori, jonka katseluohjelma luo, on myös katseluohjelman itsensä omistama, TEdit.Create(Self) TEdit.Create(nil):n sijaan, joten jopa ohjausobjekti, joka on yhä pysäköitynä FDeadEditor:iin, kun katseluohjelma tuhotaan, lakaistaan tavallisella VCL-komponenttiomistuksella vuotamisen sijaan

procedure TPDFlibViewer.ReapDeadEditor;
begin
  if Assigned(FDeadEditor) then
  begin
    FDeadEditor.Free;          // safe now: this control's own OnExit
    FDeadEditor := nil;        // finished at least one edit cycle ago
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // flush whatever editor is still open
  // ... field lookup and rectangle conversion omitted ...
  ReapDeadEditor;              // now safe to free last cycle's parked editor
  Edit := TEdit.Create(Self);
  Edit.Parent := Self;
  Edit.OnExit := InplaceEditorExit;
  FInplaceEditor := Edit;
  FInplaceEditor.SetFocus;
  Result := 1;
end;

Miksi tämä näkyy vaikeimmin nopean Tab-navigoinnin aikana?

FocusNextFormField, metodi, jonka PDFlibPas lisäsi v3.226.0:ssa ohjatakseen Tab- ja Shift+Tab-navigointia lomakkeen yli, kutsuu BeginEditFormField:ää seuraavalle kelvolliselle kentälle jokaisella yksittäisellä hypyllä, ja BeginEditFormField alkaa kutsumalla CommitInplaceEditor(True) huuhdellakseen minkä tahansa editorin, jonka edellinen kenttä jätti auki. Se tarkoittaa, että jokainen Tab-painallus, jonka käyttäjä tekee täyttäessään monikenttäistä lomaketta, ajaa täsmälleen yllä kuvatun irrota-sitten-kosketa-sekvenssin kerran, mikä on juuri se koodipolku, jossa on todennäköisimmin ohjausobjekti aidosti fokusoituna sillä hetkellä, kun Visible ja Parent muuttuvat, koska Tab on se yksi vuorovaikutus, joka on lähes taatusti jättävä lähtevän editorin pitämään fokusta aina siihen asti, kunnes uusi pyytää sitä

Mikään tästä ei tee bugista luotettavaa osoitettavaa, ja se kannattaa sanoa suoraan sen sijaan, että se sivuutettaisiin. Se, pakottaako tietty Visible- tai Parent-sijoitus todella synkronisen OnExit:n, riippuu fokus- ja ikkunakahvatilasta, jota debuggeri muuttaa jo pelkästään kiinnittymällä, jota toisiinsa liittymätön uudelleenpiirto tai ajastin voi häiritä, ja joka käyttäytyy eri tavalla riippuen siitä, mikä TEdit-, TMemo- tai TComboBox-oliosta sattuu olemaan kyseessä oleva ohjausobjekti. Vartija, joka vain joskus tulee harjoitetuksi, on syy siihen, miksi tällainen vika selviää sekä koodikatselmuksesta että manuaalisesta testauksesta, ja se on myös syy siihen, miksi korjauksen on oltava oikea rakenteensa ansiosta, nillaten viittauksen ennen kuin mitään muuta tapahtuu, ei oikea sen mukaan, mitä käytöstä kourallinen manuaalisia testikierroksia sattui havaitsemaan

Tämän korjauksen yleinen muoto kulkee paljon yhtä katseluohjelmakomponenttia pidemmälle. Mikä tahansa mukautettu muokkauspinta, joka on rakennettu kerrostamalla elävä VCL-ohjausobjekti renderöidyn sisällön päälle, ei vain PDF-lomakekenttä, perii saman vaaran heti, kun sen sulje-ja-sido-logiikan voi laukaista sekä nimenomainen käyttäjätoiminto että implisiittinen fokuksenmuutos, ja sama kaksiosainen vastaus pätee: tyhjennä viittaus, joka tunnistaa aktiivisen ohjausobjektin, ennen kuin teet mitään, mikä saattaisi laukaista sen oman exit-tapahtuman, äläkä koskaan kutsu Free:tä koodipolusta, joka saattaisi yhä olla käynnissä tuon ohjausobjektin oman tapahtumajakelun alla. TPDFlibViewer:n laajempi lomakkeentäyttö- ja renderöintipinta, mukaan lukien miten se päättää, minkä ohjausobjektityypin näyttää millekin kentälle, käsitellään artikkelissa yleiskatsaus interaktiivisen PDF-katseluohjelmakomponentin rakentamiseen Delphi VCL:ssä PDFlibPas:lla, ja sivun bittikarttavälimuisti, jonka SetFormFieldValueAndRefresh joutuu mitätöimään jokaisella sidotulla muokkauksella, käsitellään erikseen artikkelissa artikkeli katseluohjelman monitorikohtaisesta DPI-levysivuvälimuistista

Paikallinen lomakekenttämuokkaus, Tab-ohjattu kentän navigointi ja niiden molempien takana oleva uudelleenajautuvuusturvallinen sitomispolku ovat osa interaktiivista katseluohjelmakomponenttia, joka toimitetaan PDFlibPas:n, PDF-kirjaston Delphille ja C++Builderille, mukana, yhdessä sen muun sivunrenderöinti-, annotaatio- ja lomakekenttä-API-pinnan kanssa