FlexGrid のカスタムエディタでお友達のグリッドに差をつけよう! (同時上映: 続・パフォーマンスの話)

Wijmoウィジモ の FlexGrid には、セルの入力コンポーネントの種類を変更できるカスタムエディタという隠し機能があります。

ちょっとクセはあるのですが、使いこなせばお友達に差をつけることができます!

TL; DR

  • FlexGrid のカスタムエディタを導入すると入力のバリエーションが広がる
  • 一方で、カスタムエディタを(Angular アプリケーションに)導入すると処理性能が劣化する
  • カスタムエディタを NgZone で性能改善するにはトリッキーなコーディングが必要

ナイスな導入

グリッドの中で、「この項目だけはコンボボックスにしたいのに」って思うこと、あるよね。

あるあるだね。

Wijmo ならカスタムエディタでできるよ。
公式サイトのサンプルをキャプってみた。

f:id:tercel_s:20200122200522p:plain
カスタムエディタを搭載した FlexGrid

そうそう、こういうのがやりたかった。

サンプルソース一式はこちらの公式サイトから zip でダウンロードできるので、興味のある人はぜひ解析してみよう。

初心者向け解説は無いのか……orz


カスタムエディタとは

カスタムエディタは、FlexGrid のセルがアクティブになったときの入力コンポーネント(= エディタ)の種類を変える機能なんだけど、エディタの振る舞い自体は自力でコーディングしなきゃいけないんだ。

FlexGrid に限らず、多くのグリッドでは、エディタのインスタンスを(エディタの種類ごとに)一つずつ用意しておき、それを使い回す方式が採られているよね。

そうそう。 だから、セルがアクティブになったときだけ、そこにぴったり合わせる形で適切な種類のエディタを表示させたり、入力が完了したらまた隠したりといった処理が必要になるんだ。

あとはスクロールしたりサイズが変わったりしたときも、セルとエディタの位置がずれないように処置が必要だね。

それらの必要な処理を一つのクラスにまとめたのが CustomGridEditor クラスなんだ。

このクラスは Wijmo が製品として提供しているものではなく、自前のものをアプリに組み込む必要がある。

ちょっと長いけどソースコードを示すよ。

以下のソースコードは GrapeCity さんが公開している 2020年1月25日現在のサンプルをベースにしていますが、 TSLint の警告を消すため、筆者によってコードの一部が改編されています。 ご了承ください。

app.cutomEditor.ts

import { Control, Key, Point, isUndefined, setCss, setSelectionRange } from '@grapecity/wijmo';
import { FlexGrid, Column, CellRange, CellRangeEventArgs, CellEditEndingEventArgs } from '@grapecity/wijmo.grid';
import { DropDown } from '@grapecity/wijmo.input';

//
// *** CustomGridEditor class (transpiled from TypeScript) ***
//
export class CustomGridEditor {

  // tslint:disable: variable-name
  // tslint:disable: deprecation
  // tslint:disable: no-string-literal

  private static _isEditing: boolean; // true if any custom editor is active

  private _grid: FlexGrid;
  private _col: Column;
  private _ctl: Control;
  private _openDropDown: boolean;
  private _rng: CellRange;

  /**
   * Initializes a new instance of a CustomGridEditor.
   */
  constructor(flex: FlexGrid, binding: string, edtClass: any, options: any) {

    // save references
    this._grid = flex;
    this._col = flex.columns.getColumn(binding);

    // create editor
    this._ctl = new edtClass(document.createElement('div'), options);

    // connect grid events
    flex.beginningEdit.addHandler(this._beginningEdit, this);
    flex.sortingColumn.addHandler(() => {
      this._commitRowEdits();
    });
    flex.scrollPositionChanged.addHandler(() => {
      if (this._ctl.containsFocus()) {
        flex.focus();
      }
    });
    flex.selectionChanging.addHandler((s, e: CellRangeEventArgs) => {
      if (e.row !== s.selection.row) {
        this._commitRowEdits();
      }
    });

    // connect editor events
    this._ctl.addEventListener(this._ctl.hostElement, 'keydown', (e: KeyboardEvent) => {
      switch (e.keyCode) {
        case Key.Tab:
        case Key.Enter:
          e.preventDefault(); // TFS 255685
          this._closeEditor(true);
          this._grid.focus();

          // forward event to the grid so it will move the selection
          const evt = document.createEvent('HTMLEvents') as any;
          evt.initEvent('keydown', true, true);
          'altKey,metaKey,ctrlKey,shiftKey,keyCode'.split(',').forEach((prop) => {
            evt[prop] = e[prop];
          });
          this._grid.hostElement.dispatchEvent(evt);
          break;

        case Key.Escape:
          this._closeEditor(false);
          this._grid.focus();
          break;
      }
    });

    // close the editor when it loses focus
    this._ctl.lostFocus.addHandler(() => {
      setTimeout(() => { // Chrome/FireFox need a timeOut here... (TFS 138985)
        if (!this._ctl.containsFocus()) {
          this._closeEditor(true); // apply edits and close editor
          this._grid.onLostFocus(); // commit item edits if the grid lost focus
        }
      });
    });

    // commit edits when grid loses focus
    this._grid.lostFocus.addHandler(() => {
      setTimeout(() => { // Chrome/FireFox need a timeOut here... (TFS 138985)
        if (!this._grid.containsFocus() && !CustomGridEditor._isEditing) {
          this._commitRowEdits();
        }
      });
    });

    // open drop-down on f4/alt-down
    this._grid.addEventListener(this._grid.hostElement, 'keydown', (e: KeyboardEvent) => {

      // open drop-down on f4/alt-down
      this._openDropDown = false;
      if (e.keyCode === Key.F4 ||
        (e.altKey && (e.keyCode === Key.Down || e.keyCode === Key.Up))) {
        const colIndex = this._grid.selection.col;
        if (colIndex > -1 && this._grid.columns[colIndex] === this._col) {
          this._openDropDown = true;
          this._grid.startEditing(true);
          e.preventDefault();
        }
      }

      // commit edits on Enter (in case we're at the last row, TFS 268944)
      if (e.keyCode === Key.Enter) {
        this._commitRowEdits();
      }
    }, true);

    // close editor when user resizes the window
    // REVIEW: hides editor when soft keyboard pops up (TFS 326875)
    window.addEventListener('resize', () => {
      if (this._ctl.containsFocus()) {
        this._closeEditor(true);
        this._grid.focus();
      }
    });
  }

  // gets an instance of the control being hosted by this grid editor
  get control() {
    return this._ctl;
  }

  // handle the grid's beginningEdit event by canceling the built-in editor,
  // initializing the custom editor and giving it the focus.
  private _beginningEdit(grid: FlexGrid, args: CellRangeEventArgs) {

    // check that this is our column
    if (grid.columns[args.col] !== this._col) {
      return;
    }

    // check that this is not the Delete key
    // (which is used to clear cells and should not be messed with)
    const evt = args.data;
    if (evt && evt.keyCode === Key.Delete) {
      return;
    }

    // cancel built-in editor
    args.cancel = true;

    // save cell being edited
    this._rng = args.range;
    CustomGridEditor._isEditing = true;

    // initialize editor host
    const rcCell = grid.getCellBoundingRect(args.row, args.col);
    const rcBody = document.body.getBoundingClientRect();
    const ptOffset = new Point(-rcBody.left, -rcBody.top);
    const zIndex = (args.row < grid.frozenRows || args.col < grid.frozenColumns) ? '3' : '';
    setCss(this._ctl.hostElement, {
      position: 'absolute',
      left: rcCell.left - 1 + ptOffset.x,
      top: rcCell.top - 1 + ptOffset.y,
      width: rcCell.width + 1,
      height: grid.rows[args.row].renderHeight + 1,
      borderRadius: '0px',
      zIndex, // TFS 291852
    });

    // initialize editor content
    if (!isUndefined(this._ctl['text'])) {
      this._ctl['text'] = grid.getCellData(this._rng.row, this._rng.col, true);
    } else {
      throw new Error('Can\'t set editor value/text...');
    }

    // start editing item
    const ecv = grid.editableCollectionView;
    const item = grid.rows[args.row].dataItem;
    if (ecv && item && item !== ecv.currentEditItem) {
      setTimeout(() => {
        grid.onRowEditStarting(args);
        ecv.editItem(item);
        grid.onRowEditStarted(args);
      }, 50); // wait for the grid to commit edits after losing focus
    }

    // activate editor
    document.body.appendChild(this._ctl.hostElement);
    this._ctl.focus();
    setTimeout(() => {

      // get the key that triggered the editor
      const key = (evt && evt.charCode > 32)
        ? String.fromCharCode(evt.charCode)
        : null;

      // get input element in the control
      const input: HTMLInputElement = this._ctl.hostElement.querySelector('input');

      // send key to editor
      if (input) {
        if (key) {
          input.value = key;
          setSelectionRange(input, key.length, key.length);
          const evtInput = document.createEvent('HTMLEvents');
          evtInput.initEvent('input', true, false);
          input.dispatchEvent(evtInput);
        } else {
          input.select();
        }
      }

      // give the control focus
      if (!input && !this._openDropDown) {
        this._ctl.focus();
      }

      // open drop-down on F4/alt-down
      if (this._openDropDown && this._ctl instanceof DropDown) {
        this._ctl.isDroppedDown = true;
        this._ctl.dropDown.focus();
      }

    }, 50);
  }

  // close the custom editor, optionally saving the edits back to the grid
  private _closeEditor(saveEdits: boolean) {
    if (this._rng) {
      const flexGrid = this._grid;
      const ctl = this._ctl;
      const host = ctl.hostElement;

      // raise grid's cellEditEnding event
      const e = new CellEditEndingEventArgs(flexGrid.cells, this._rng);
      flexGrid.onCellEditEnding(e);

      // save editor value into grid
      if (saveEdits) {
        if (!isUndefined(ctl['value'])) {
          this._grid.setCellData(this._rng.row, this._rng.col, ctl['value']);
        } else if (!isUndefined(ctl['text'])) {
          this._grid.setCellData(this._rng.row, this._rng.col, ctl['text']);
        } else {
          throw new Error('Can\'t get editor value/text...');
        }
        this._grid.invalidate();
      }

      // close editor and remove it from the DOM
      if (ctl instanceof DropDown) {
        ctl.isDroppedDown = false;
      }
      host.parentElement.removeChild(host);
      this._rng = null;
      CustomGridEditor._isEditing = false;

      // raise grid's cellEditEnded event
      flexGrid.onCellEditEnded(e);
    }
  }

  // commit row edits, fire row edit end events (TFS 339615)
  private _commitRowEdits() {
    const flexGrid = this._grid;
    const ecv = flexGrid.editableCollectionView;
    this._closeEditor(true);
    if (ecv && ecv.currentEditItem) {
      const e = new CellEditEndingEventArgs(flexGrid.cells, flexGrid.selection);
      ecv.commitEdit();
      setTimeout(() => { // let cell edit events fire first
        flexGrid.onRowEditEnding(e);
        flexGrid.onRowEditEnded(e);
        flexGrid.invalidate();
      });
    }
  }
}

……長いね。

CustomGridEditor自体は、基本的にコピペ利用で問題ないと思う。

後述するとおり性能上の課題はあるんだけど。

ベーシックな機能はひととおり実装されていそうだしね。

CustomGridEditor の定義はいいとして、実際の使い方を見ていこう。

早く本題に行きたい。


カスタムエディタの使い方はちょっと独特

今日の本題に入る前に、少しだけカスタムエディタの予備知識をお話ししておきたいので、しばしお付き合いください。

ここで CustomGridEditor利用する側のサンプルコードを見てみよう。

公式サンプルから、当該箇所を抜粋してみたよ。 何か気になることはないかな?

    initializeGrid(flex: wjcGrid.FlexGrid) {
        // add custom editors to the grid
        new CustomGridEditor(flex, 'date', wjcInput.InputDate, {
            format: 'd'
        });
        new CustomGridEditor(flex, 'time', wjcInput.InputTime, {
            format: 't',
            min: new Date(2000, 1, 1, 7, 0),
            max: new Date(2000, 1, 1, 22, 0),
            step: 30
        });
        new CustomGridEditor(flex, 'country', wjcInput.ComboBox, {
            itemsSource: this._countries
        });
        new CustomGridEditor(flex, 'amount', wjcInput.InputNumber, {
            format: 'n2',
            step: 10
        });
        /* (中略) */
    }

あれ…… CustomGridEditor クラスを毎回 new している……?

気付いたかな? DI に慣れた人ほど違和感が大きいと思う。

Angular では、オブジェクトの生成をフレームワークに一任するのが普通なので、プログラマが自力で new を書くケースは少ないんだ。

new CustomGridEditor( foo, bar, baz, ... );

みたいに、コンストラクタに直接パラメータを引き渡しているけど、これは大丈夫かな。

うーん。 これだと、CustomGridEditor クラスに Angular のサービスを注入したくなったとき、途端に困ったことになる

どういうこと??

たとえばパフォーマンス改善のために CustomGridEditor から NgZone を使いたくなったとき、どうすればいいと思う?

今日の問題とソリューション

カスタムエディタを使うとパフォーマンスが落ちるので手当をしないといけないのにそのままでは NgZone を DI できない

なんだこのラノベのタイトルみたいな見出しは。

カスタムエディタを組み込んだグリッドで、どのくらい Change Detection が無駄打ちされているかを確認してみたんだけど、ちょっとすごかった

ものすごい勢いで Change Detection が走ってるじゃないですか。

そうだね。

定石どおり NgZone を使ってパフォーマンスチューニングしたいけど、一筋縄ではいかない。

CustomGridEditor利用する側new で直接コンストラクタを呼んでいるから、フレームワークによる DI の恩恵を受けられないんだね。

そういうこと。

もし仮に、CustomGridEditor 側のコンストラクタをこんなふうにいじったら、たちどころにコンパイルエラーの嵐だよ。


  /**
   * Initializes a new instance of a CustomGridEditor.
   */
  constructor(flex: FlexGrid, binding: string, edtClass: any, options: any, private _zone: NgZone) {
    /* 略 */
  }

パラメータを勝手に増やしたらエラーになるのは当然だし、カスタムエディタを new している側のコードを全て横展開修正しなきゃいけなくなるね。

そうだね。

「コンストラクタを修正したから、皆さん今からパラメータを全部直してください」なんてアナウンスをしたら弾劾裁判が始まっちゃうよ。

それだけはイヤだ。

なんとか、人様に迷惑をかけない形でパフォーマンスを改善したいよね。


NgZone の実体はどこにある?

実は、NgZone はシングルトンオブジェクトなので、アプリ内のどこかには存在するんだ。 隠されているけど。

じゃあ、NgZoneどこかから取得できれば、CustomGridEditor でも NgZone が使えるんだね。

そう。 答えを言ってしまうと、NgZoneInjector 経由でアクセスできる。

かなりトリッキーな解法になるけど、まずはこんなクラスを定義するよ。

static-injector.ts

import { Injector } from '@angular/core';

export class StaticInjector {
  // tslint:disable-next-line:variable-name
  private static _injector: Injector;

  static get injector() {
    if (!StaticInjector._injector) {
      throw new Error('Injector is not initialized yet.');
    }
    return StaticInjector._injector;
  }

  static initialize(injector: Injector) {
    if (!injector) {
      throw new Error('Cannot set Injector to null.');
    }
    if (!!StaticInjector._injector) {
      throw new Error('Injector is already initialized.');
    }
    StaticInjector._injector = injector;
  }
}

次に、Root モジュールのコンストラクタで、さっきの StaticInjector を初期化するんだ。

app.module.ts

import { NgModule, Injector } from '@angular/core';

import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { WjGridModule } from '@grapecity/wijmo.angular2.grid';

import { AppComponent } from './app.component';

import { StaticInjector } from './static-injector';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    WjGridModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule {

  // StaticInjector を初期化
  constructor(injector: Injector) {
    StaticInjector.initialize(injector);
  }
}

これで、アプリ内の任意の場所から NgZone にアクセスできるようになった。 以下のように書いて使う。


const zone = StaticInjector.instance.get<NgZone>(NgZone);

Injectorインスタンスstatic アクセスできるところがポイントだね。

そうだね。

これなら注入なしで NgZone を取れるね。

でもそれなら、わざわざ Injector を経由させなくても、直接 NgZone を公開するクラスを作ればよかったんじゃないかな?

まぁ、それでも構わないけど、Injector を公開するクラスの方が汎用性があってボクは好きだな。


使えるようになった NgZone で CustomGridEditor の性能改善

じゃあ、ボトルネックになりそうなところを丸ごと NgZone#runOutsideAngular() で包んでいこう。

コンストラクタのイベント登録のあたりは、のきなみ Angular の監視下から外してやる必要がある。

app.customEditor.ts・改 (コンストラクタのみ)

  /**
   * Initializes a new instance of a CustomGridEditor.
   */
  constructor(flex: FlexGrid, binding: string, edtClass: any, options: any, 
              private _zone = StaticInjector.injector.get<NgZone>(NgZone)) {

    // save references
    this._grid = flex;
    this._col = flex.columns.getColumn(binding);

    // create editor
    this._ctl = new edtClass(document.createElement('div'), options);

    // connect grid events
    flex.beginningEdit.addHandler(this._beginningEdit, this);

    // イベント処理を極力 Angular の監視外で実行する
    this._zone.runOutsideAngular(() => {
      flex.sortingColumn.addHandler(() => {
        this._zone.run(() => this._commitRowEdits());
      });

      flex.scrollPositionChanged.addHandler(() => {
        if (this._ctl.containsFocus()) {
          flex.focus();
        }
      });

      flex.selectionChanging.addHandler((s, e: CellRangeEventArgs) => {
        if (e.row !== s.selection.row) {
          this._zone.run(() => this._commitRowEdits());
        }
      });

      // connect editor events
      this._ctl.addEventListener(this._ctl.hostElement, 'keydown', (e: KeyboardEvent) => {
        switch (e.keyCode) {
          case Key.Tab:
          case Key.Enter:
            e.preventDefault(); // TFS 255685
            this._zone.run(() => this._closeEditor(true));
            this._grid.focus();

            // forward event to the grid so it will move the selection
            const evt = document.createEvent('HTMLEvents') as any;
            evt.initEvent('keydown', true, true);
            'altKey,metaKey,ctrlKey,shiftKey,keyCode'.split(',').forEach((prop) => {
              evt[prop] = e[prop];
            });
            this._grid.hostElement.dispatchEvent(evt);
            break;

          case Key.Escape:
            this._zone.run(() => this._zone.run(() => this._closeEditor(false)));
            this._grid.focus();
            break;
        }
      });

      // close the editor when it loses focus
      this._ctl.lostFocus.addHandler(() => {
        setTimeout(() => { // Chrome/FireFox need a timeOut here... (TFS 138985)
          if (!this._ctl.containsFocus()) {
            this._zone.run(() => {
              this._closeEditor(true); // apply edits and close editor
            });
            this._grid.onLostFocus(); // commit item edits if the grid lost focus
          }
        });
      });

      // commit edits when grid loses focus
      this._grid.lostFocus.addHandler(() => {
        setTimeout(() => { // Chrome/FireFox need a timeOut here... (TFS 138985)
          if (!this._grid.containsFocus() && !CustomGridEditor._isEditing) {
            this._zone.run(() => this._commitRowEdits());
          }
        });
      });

      // open drop-down on f4/alt-down
      this._grid.addEventListener(this._grid.hostElement, 'keydown', (e: KeyboardEvent) => {
        // open drop-down on f4/alt-down
        this._openDropDown = false;
        if (e.keyCode === Key.F4 ||
          (e.altKey && (e.keyCode === Key.Down || e.keyCode === Key.Up))) {
          const colIndex = this._grid.selection.col;
          if (colIndex > -1 && this._grid.columns[colIndex] === this._col) {
            this._openDropDown = true;
            this._zone.run(() => this._grid.startEditing(true));
            e.preventDefault();
          }
        }

        // commit edits on Enter (in case we're at the last row, TFS 268944)
        if (e.keyCode === Key.Enter) {
          this._zone.run(() => this._commitRowEdits());
        }
      }, true);

      // close editor when user resizes the window
      // REVIEW: hides editor when soft keyboard pops up (TFS 326875)
      window.addEventListener('resize', () => {
        if (this._ctl.containsFocus()) {
          this._zone.run(() => this._closeEditor(true));
          this._grid.focus();
        }
      });
    });
  }

コンパイルして動かしてみ。

あ、ほんのちょっとだけ無駄打ちが改善した。

そう、Change Detection が走る回数は明らかに減ったんだけど、まだまだ多いんだよね……(50回が20回になっただけの感じ)。

これ以上どうにもならないのかなぁ?

Wijmo の内部で、window.resizewindow.keydown イベントが Change Detection の無駄打ちに繋がっていそうな気はしている。

少なくとも Chrome は、キーを押している間ずっと keydown が発火し続ける。

その都度いちいち Change Detection が走っているから、思うような性能が出ないんだと思う。

これ、もう Wijmo が悪いんじゃなくて Angular の非効率極まりない変更検知が悪いような気がしてきた……。

敢えて試してないけど React や Vue に組み込んだ場合どうなんだろう。 場合によっては Angular を捨t

GrapeCity さん! おねがいします! Angular を見捨てないでください!

つづく。

おまけ

カスタムエディタのサンプルの動きが残念な件


Flyweight パターン

本文中で、僕がサラっと「FlexGrid に限らず、多くのグリッドでは、エディタのインスタンスを(エディタの種類ごとに)一つずつ用意しておき、それを使い回す方式が採られている」 という発言をしていますが、これはいにしえの Windows Forms の時代からそういう思想のもとにコンポーネントが構築されていました。

インスタンスの使い回しは、Flyweight パターンと呼ばれるれっきとしたデザインパターンです。

Flyweight パターンで使っている技法は、一言でいえば、

インスタンスをできるだけ共有させて、無駄に new しない」

というものです。 インスタンスが必要なときに、いつも new するのではなく、すでに作ってあるインスタンスを利用できるなら、それを共有して使う。 …これが Flyweight パターンの核心です。

【出典】
結城 浩 『増補改訂版 Java 言語で学ぶデザインパターン入門』, ソフトバンククリエイティブ, 2004, p.308.

new とコードのにお

本文中で、「CustomGridEditornew して使う」ことに対して違和感を抱くくだりがあります。

今回は話を簡単にするためあまり深くは突っ込みませんでしたが、欄外でちょっとだけ触れておこうと思います。

大抵のプログラミング言語の入門書では、インスタンスを生成する最も初歩的な手法として new の利用を挙げていますが、一般的にはあまりよい方法ではなく、Factory Method への置き換えを検討すべきといわれています。

コードの臭いには、さまざまな種類があります。 new キーワードの使用 ———— クラスの直接のインスタンス———— は、「不適切な関係」の一例です。 コンストラクターは実装上の詳細であるため、それらを使用すれば、意図しない(そして望ましくない)依存関係がクライアントコードに要求されることになるかもしれません。

【出典】
Gary McLean Hall 『C# 実践開発手法 デザインパターンと SOLID 原則によるアジャイルなコーディング』, 日経BP, 2015, p.57.

(前略)new を使ってインスタンスを生成するとき、new の後には具体的なクラス名を書きます。 この場合、クラス名を変更するのは難しくなります。 なぜなら、プログラム中でインスタンスを new している箇所をすべて変更しなければならないからです。 そんなときには 《Factory Method によるコンストラクタの置き換え》 というリファクタリングを検討しましょう。

【出典】
結城 浩 『Java 言語で学ぶリファクタリング入門』, ソフトバンククリエイティブ, 2007, p.10

これに関して、Angular の流儀でよりマシな方法として、Angular After Tutorial でファクトリプロバイダが紹介されています。

まぁ今回に関しては、『既存のクライアントコードを絶対に壊さないこと』とボスから命令されたので、new は温存する形となってしまいました。

では、また。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.