lacolaco

Google Developers Expert for Angular / 技術的なことや技術的じゃないことを書きます

FlutterのBLoCパターンをAngularで理解する

この記事ではAngularDart/Flutterの文脈で新しいコンポーネント設計パターンとして広まりつつあるBLoCパターンを、Angularの語彙で理解し、実装する方法を紹介する。

BLoCパターンとは

BLoCとは、Business Logic Componentの略である。 BLoCを使ったアプリケーションの実装パターンをBLoCパターンと呼ぶ。

まず誤解を招きそうなポイントとして、この"Component"はReactやAngularなどでいうところのビューを構築する"コンポーネント"ではない。 一般的な単語としての、アプリケーションを構成するひとかたまりの要素という意味の"Component"なので誤解しないこと。 対比するレベルとしては、"UI Component" vs "Business Logic Component"のようになる。

BLoCは複数の環境向けにアプリケーションを開発するときのコードシェアカバレッジを高めるための、リファクタリング指針のようなものだ。 具体的には、以下の指針を与える。

  1. BLoCの入力・出力インターフェースはすべてStream/Sinkである
  2. BLoCの依存は必ず注入可能で、環境に依存しない
  3. BLoC内に環境ごとの条件分岐は持たない
  4. 以上のルールに従う限り実装は自由である

詳しくはBLoCパターンの初出であるこのセッションを見るとよい。

Flutter / AngularDart – Code sharing, better together (DartConf 2018) - YouTube

AngularにおけるBLoCパターン

AngularにおいてBLoCパターンの恩恵がどれほどあるのかは議論の余地があるが、 持続可能なAngularアプリケーション開発のために大事なこと - lacolaco でも述べたようにフレームワークに依存しない部分を明確に分ける、というのは設計指針として重要である。 サーバーサイドでの実行やNativeScript、Ionic、あるいはReact/Vueなどへの換装など考えても、BLoCパターンはアプリケーションのAngular依存度を適切に保つために良いルールに思える。

さて、さっそくAngularでBLoCを実装してみよう。 Dartには言語標準のStreamとSinkがあるが、JavaScriptにはまだ存在しないため、非標準の実装が必要である。 幸運にもAngularはRxJSと相互運用可能なので、RxJSのObservableをStreamに見立ててBLoCを実装することができる。

まずはUIコンポーネントビジネスロジックを持ってしまった状態の例を以下に挙げる。

https://stackblitz.com/edit/angular-bloc-example-1?file=src%2Fapp%2Fapp.component.ts

@Component({
  selector: 'my-app',
  template: `
  <div cdkTrapFocus [cdkTrapFocusAutoCapture]="false">
    <mat-form-field appearance="outline" style="width: 80%;">
      <input 
        matInput
        placeholder="Search for..." 
        ngModel (ngModelChange)="onInputChange($event)">
    </mat-form-field>
  </div>

  <span> {{preamble}} </span>

  <ul>
    <li *ngFor="let result of results">
      {{ result }}
    </li>
  </ul>
  `,
})
export class AppComponent {
  private query = '';
  results: string[] = [];

  get preamble() {
    return this.query == null || this.query.length == 0 ? '' : `Results for ${this.query}`;
  }

  constructor(private repository: SearchRepository) {}

  onInputChange(query: string) {
    this.query = query;
    this.executeSearch(query);
  }

  private async executeSearch(query: string) {
    const results = await this.repository.search(query);
    this.results = results;
  }
}

UIコンポーネントAPIの呼び出しや状態の保持などさまざまなビジネスロジックを持っているので、もしこのアプリケーションを別プラットフォームにも展開したくなってもコードが共有できない。

BLoCの作成

BLoCはポータビリティを考えると、ほとんどの場合は単なるクラスとして宣言される。 ここではSearchBlocクラスを作成する。 もともとAppComponentが持っていたビジネスロジックをすべてSearchBlocに移動すると次のようになる。

class SearchBloc {
  private query = '';
  results: string[] = [];


  get preamble() {
    return this.query == null || this.query.length == 0 ? '' : `Results for ${this.query}`;
  }

  constructor(private repository: SearchRepository) {
  }

  async executeSearch(query: string) {
    this.query = query;
    const results = await this.repository.search(query);
    this.results = results;
  }
}

そしてAppComponentSearchBlocに依存して次のようになる。

@Component({
  selector: 'my-app',
  template: `
  <div cdkTrapFocus [cdkTrapFocusAutoCapture]="false">
    <mat-form-field appearance="outline" style="width: 80%;">
      <input 
        matInput
        placeholder="Search for..." 
        ngModel (ngModelChange)="bloc.executeSearch($event)">
    </mat-form-field>
  </div>

  <span> {{ bloc.preamble }} </span>

  <ul>
    <li *ngFor="let result of bloc.results">
      {{ result }}
    </li>
  </ul>
  `,
})
export class AppComponent {
  bloc: SearchBloc;

  constructor(private repository: SearchRepository) {
    this.bloc = new SearchBloc(this.repository);
  }
}

https://stackblitz.com/edit/angular-bloc-example-2?file=src/app/app.component.ts

Observableへのリファクタリング

先述のとおり、BLoCパターンではBLoCのすべてのインターフェースはStreamでなければならない。 これはFlutterのStatefulWidgetやAngularDartのChange Detectionの間で、データの変更に対するUIのリアクションのアプローチが違うからだ。 同期的な状態の管理ではプラットフォームごとに特別な処理が必要になる。

一方StreamであればFlutterはStreamBuilderでStreamからデータが流れてくるたびに再描画する仕組みをもっており、AngularDartもasyncパイプにより同様の反応機構をもっている。 プラットフォームに依存せず非同期的な値を描画するために、DartBLoCパターンではStreamを活用する。

Angularの場合はRxJSがBLoCの実装を助けてくれる。

DartのStreamをObservable、SinkをObserverに置き換えると、SearchBlocは次のようになる。

class SearchBloc {
  private _results$: Observable<string[]>
  get results$(): Observable<string[]> {
    return this._results$;
  }

  private _preamble$: Observable<string>
  get preamble$(): Observable<string> {
    return this._preamble$;
  }

  private _query$ = new BehaviorSubject<string>('');
  get query(): Observer<string> {
    return this._query$;
  }

  constructor(private repository: SearchRepository) {
    this._results$ = this._query$
      .pipe(
      switchMap(query => observableFrom(this.repository.search(query)))
      );
    this._preamble$ = this.results$.pipe(
      withLatestFrom(this._query$, (_, q) => q ? `Results for ${q}` : '')
    );
  }

  dispose() {
    this._query$.complete();
  }
}

results: string[]results$: Observable<string[]>になり、preamble: stringpreamble$: Observable<string>となった。 これらはqueryの変更に反応して変化する非同期的な値として表現される。

queryObserver<string>インターフェースを外部に公開し、新しい値の追加をUIに許可する。 SearchBlocの内部では_query$: BehaviorSubject<string>を実体として持ち、コンストラクタでは_query$に反応する_results$_preamble$が宣言されている。

これをAppComponentから使うと次のようになる。テンプレート中でasyncパイプを使い、Observableの変更に反応してビューの再描画が実行されるようになる。

@Component({
  selector: 'my-app',
  template: `
  <div cdkTrapFocus [cdkTrapFocusAutoCapture]="false">
    <mat-form-field appearance="outline" style="width: 80%;">
      <input 
        matInput
        placeholder="Search for..." 
        ngModel (ngModelChange)="bloc.query.next($event)">
    </mat-form-field>
  </div>

  <span> {{ bloc.preamble$ | async }} </span>

  <ul>
    <li *ngFor="let result of bloc.results$ | async">
      {{ result }}
    </li>
  </ul>
  `,
})
export class AppComponent {
  bloc: SearchBloc;

  constructor(private repository: SearchRepository) {
    this.bloc = new SearchBloc(this.repository);
  }

  ngOnDestroy() {
    this.bloc.dispose();
  }
}

https://stackblitz.com/edit/angular-bloc-example-3?file=src/app/app.component.ts

これでBLoCの実装が完了した。

考察

まとめ