NativeScript Angular移动开发

Viewed 1

第九章:赋能你的视图

Angular 与 NativeScript 的结合为移动开发带来了强大的功能和乐趣。无论是需要与音频录制等硬件功能交互的服务,还是通过丰富的视图来增强应用体验,NativeScript for Angular 都提供了令人兴奋的可能性。

本章我们将延续上一章的概念,为音轨列表提供另一种丰富的视图,同时复用已涵盖的知识并引入一些新技巧。

使用 ListView 的多项行模板

在第八章构建音频录制器时,我们创建了一个多功能的自定义 NativeScript Waveform 视图。现在,让我们复用这个视图来增强音轨列表。目标是提供两种不同的方式来查看和处理音轨:一种是默认的列表视图,另一种是带有波形和音量推子(在音频制作中常称为 Fader)的混音视图,允许用户调整每个音轨在整体作品中的音量。同时,我们也将最终启用音轨的“静音”开关。

首先,修改 track-list.component.html 中的 ListView

<ListView #listview [items]="tracks | orderBy: 'order'" class="list-group" [itemTemplateSelector]="templateSelector">
  <ng-template let-track="item" nsTemplateKey="default">
    <GridLayout rows="auto" columns="100,*,100" class="list-group-item" [class.muted]="track.mute">
      <Button text="Record" (tap)="record(track)" row="0" col="0" class="c-ruby"></Button>
      <Label [text]="track.name" row="0" col="1" class="h2"></Label>
      <Switch row="0" col="2" class="switch" [(ngModel)]="track.mute"></Switch>
    </GridLayout>
  </ng-template>

  <ng-template let-track="item" nsTemplateKey="waveform">
    <AbsoluteLayout [class.muted]="track.mute">
      <Waveform class="waveform w-full" top="0" left="0" height="80" [model]="track.model" type="file" plotColor="#888703" fill="true" mirror="true" plotType="buffer"></Waveform>
      <Label [text]="track.name" top="5" left="20" class="h3 track-name-float"></Label>
      <Slider slim-slider="fader.png" minValue="0" maxValue="1" width="94%" top="50" left="0" [(ngModel)]="track.volume" class="slider fader"></Slider>
    </AbsoluteLayout>
  </ng-template>
</ListView>

这里的关键在于 [itemTemplateSelector]="templateSelector",它允许动态改变 ListView 的行模板。templateSelector 函数返回的字符串需要与 ng-templatensTemplateKey 属性值匹配。

接下来,在 TrackListComponent 中设置必要的逻辑来支持模板切换:

import { Component, Input, ViewChild, ElementRef } from '@angular/core';
import { ListView } from 'ui/list-view';
import { PlayerService } from '../../services/player.service';

@Component({
  moduleId: module.id,
  selector: 'track-list',
  templateUrl: 'track-list.component.html',
})
export class TrackListComponent {
  public templateSelector: Function;
  @Input() tracks: Array<ITrack>;
  @ViewChild('listview') _listviewRef: ElementRef;
  private _listview: ListView;
  private _sub: any;

  constructor(private playerService: PlayerService) {
    this.templateSelector = this._templateSelector.bind(this);
  }

  ngOnInit() {
    this._sub = this.playerService.trackListViewChange$.subscribe(() => {
      // 当模板选择器涉及的状态改变时,刷新 ListView
      this._listview.refresh();
    });
  }

  ngAfterViewInit() {
    this._listview = <ListView>this._listviewRef.nativeElement;
  }

  private _templateSelector(item: ITrack, index: number, items: ITrack[]) {
    return this.playerService.trackListViewType;
  }
  // ... 其他方法
}

我们使用 ViewChild 获取 ListView 的引用,以便在需要时调用 refresh()templateSelector 函数被绑定到组件实例,以确保它能正确访问 this.playerService 的属性。这里通过 PlayerService(一个由 CoreModule 提供的单例服务)作为通信桥梁,将状态从位于 MixerModule 中的 ActionBarComponent 传递过来。

虽然使用服务传递状态是可行的,但更好的做法是使用像 ngrx 这样的状态管理库来减少耦合并实现响应式架构。ngrx 的增强将在第十章介绍。

现在,在 MixerModuleActionBarComponent 中添加一个按钮来切换视图。修改对应的模板文件(例如 action-bar.component.ios.html):

<ActionBar [title]="title" class="action-bar">
  <ActionItem nsRouterLink="/mixer/home">
    <Button text="List" class="action-item"></Button>
  </ActionItem>
  <ActionItem (tap)="toggleList()" ios.position="right">
    <Button [text]="toggleListText" class="action-item"></Button>
  </ActionItem>
  <ActionItem (tap)="record()" ios.position="right">
    <Button text="Record" class="action-item"></Button>
  </ActionItem>
</ActionBar>

在组件类中实现切换逻辑:

import { PlayerService } from '../../../player/services/player.service';

export class ActionBarComponent {
  public toggleListText: string = 'Waveform';

  constructor(private playerService: PlayerService) { }

  public toggleList() {
    let type = this.playerService.trackListViewType === 'default' ? 'waveform' : 'default';
    this.playerService.trackListViewType = type;
    this.toggleListText = type === 'default' ? 'Waveform' : 'Default';
  }
}

最后,在 PlayerService 中实现状态管理:

import { Subject } from 'rxjs/Subject';

@Injectable()
export class PlayerService {
  public trackListViewChange$: Subject<string> = new Subject();
  private _trackListViewType: string = 'default'; // 默认状态

  public get trackListViewType() {
    return this._trackListViewType;
  }

  public set trackListViewType(value: string) {
    this._trackListViewType = value;
    this.trackListViewChange$.next(value); // 通知订阅者状态已改变
  }
}

至此,视图切换功能就连接起来了。后续我们将使用 ngrx 来改进这个设置。

使用 ngModel 数据绑定

为了使音轨视图中的 [(ngModel)] 绑定(如静音开关和音量滑块)正常工作,必须确保相关模块导入了 NativeScriptFormsModule。最佳实践是在 SharedModule 中导入并导出它,这样所有功能模块都可以使用。

import { NativeScriptFormsModule } from 'nativescript-angular/forms';

@NgModule({
  imports: [
    NativeScriptModule,
    NativeScriptRouterModule,
    NativeScriptFormsModule // 添加此行
  ],
  exports: [
    NativeScriptModule,
    NativeScriptRouterModule,
    NativeScriptFormsModule, // 并导出
    ...PIPES
  ]
})
export class SharedModule {}

连接音频播放器响应

我们需要让音频播放器能够响应每个音轨的静音和音量属性变化。这需要对 TrackModel 进行修改以支持这些新功能。

首先,在 TrackModel 中添加可观察的 volume$ 行为主体以及相关的 getter 和 setter:

import { BehaviorSubject } from 'rxjs/BehaviorSubject';

export class TrackModel implements ITrack {
  public volume$: BehaviorSubject<number>;
  private _volume: number = 1; // 默认满音量
  private _mute: boolean;
  private _origVolume: number; // 用于取消静音后恢复

  constructor(model?: ITrack) {
    this.volume$ = new BehaviorSubject(this._volume);
    // ... 其他初始化
  }

  public set mute(value: boolean) {
    this._mute = value;
    if (this._mute) {
      this._origVolume = this._volume;
      this.volume = 0;
    } else {
      this.volume = this._origVolume;
    }
  }
  public get mute() {
    return this._mute;
  }

  public set volume(value: number) {
    this._volume = value;
    this.volume$.next(this._volume); // 通知音量变化
    if (this._volume > 0 && this._mute) {
      // 如果在静音状态下增加音量,则自动取消静音
      this._origVolume = this._volume;
      this._mute = false;
    }
  }
  public get volume() {
    return this._volume;
  }
}

然后,修改 TrackPlayerModel 来订阅音轨的音量变化并控制播放器:

import { Subscription } from 'rxjs/Subscription';

export class TrackPlayerModel implements ITrackPlayer {
  public track: TrackModel; // 改为持有整个 TrackModel 引用,而不仅仅是 ID
  private _sub: Subscription;

  public load(track: TrackModel, complete: Function, error: Function): Promise<number> {
    return new Promise((resolve, reject) => {
      this.track = track;
      this._player.initFromFile({ ... }).then(() => {
        // ... 播放器初始化
        // 清理旧订阅后,订阅音轨的音量变化
        if (this._sub) this._sub.unsubscribe();
        this._sub = this.track.volume$.subscribe((value) => {
          if (this._player) {
            this._player.volume = value; // 将变化应用到播放器
          }
        });
      }, reject);
    });
  }

  public cleanup() {
    if (this.player) this.player.dispose();
    if (this._sub) this._sub.unsubscribe(); // 清理订阅
  }
}

这样,音频播放器就可以实时响应通过数据绑定进行的音量调整。静音功能本质上是通过将音量设置为 0 来实现的,并在取消静音时恢复原始音量设置。

序列化数据以持久化并在检索时恢复

我们引入的新功能(如可观察对象)带来了一个问题:MixerService 在保存包含音轨的作品时,如果直接使用 JSON.stringify 序列化包含复杂对象(如可观察对象、带有 getter/setter 的私有属性)的 TrackModel 将会失败。

为了解决这个问题,我们需要在存储前将数据序列化为简化形式,并在从持久化存储(如应用设置)检索数据时,将其重新“水合”为完整的模型。

修改 MixerService

import { knownFolders, path } from 'file-system';

export class MixerService {
  // ...

  private _saveList() {
    this.databaseService.setItem(DatabaseService.KEYS.compositions, this._serializeList());
  }

  private _serializeList() {
    let serialized = [];
    for (let comp of this.list) {
      let composition: any = Object.assign({}, comp);
      composition.tracks = [];
      for (let track of comp.tracks) {
        let serializedTrack = {};
        for (let key in track) {
          // 忽略可观察对象、私有属性(以 _ 或 $ 开头)和 waveform 模型(冗余,可恢复)
          if (!key.includes('_') && !key.includes('$') && key != 'model') {
            serializedTrack[key] = track[key];
          }
        }
        composition.tracks.push(serializedTrack);
      }
      serialized.push(composition);
    }
    return serialized;
  }

  private _hydrateList(list: Array<IComposition>) {
    for (let c = 0; c < list.length; c++) {
      let comp = new CompositionModel(list[c]);
      for (let i = 0; i < comp.tracks.length; i++) {
        comp.tracks[i] = new TrackModel(comp.tracks[i]); // 重新创建 TrackModel
        // 为波形视图恢复模型数据(针对演示音轨的路径修复)
        (<any>comp.tracks[i]).model = {
          target: fixAppLocal(comp.tracks[i].filepath)
        };
      }
      list[c] = comp; // 用“水合”后的模型更新列表引用
    }
    return list;
  }
  // ...
}

// 辅助函数:将 ~/ 开头的应用本地路径转换为绝对路径
const fixAppLocal = function (filepath: string) {
  if (filepath.indexOf('~/') === 0) {
    return path.join(knownFolders.currentApp().path, filepath.replace('~/', ''));
  }
  return filepath;
}

现在,每次保存作品时,数据都会被安全地序列化。从存储中加载时,数据会被重新注入到功能完整的模型中。

利用 Angular 指令增强 NativeScript 滑块

为了让混音视图中的音量滑块(推子)更具辨识度,我们可以使用自定义图形。例如,可以为滑块拇指创建 fader.pngfader-down.png(用于按下状态)等图片资源,并放入 App_Resources 对应的平台文件夹中。

然后,我们可以扩展之前为快进滑块创建的 SlimSliderDirective,使其能接受一个图片名称输入参数。

对于 iOS 平台 (slider.directive.ios.ts):

import { Directive, ElementRef, Input } from '@angular/core';

@Directive({
  selector: '[slim-slider]'
})
export class SlimSliderDirective {
  @Input('slim-slider') imageName: string;

  constructor(private el: ElementRef) { }

  ngAfterViewInit() {
    let uiSlider = <UISlider>this.el.nativeElement.ios;
    if (this.imageName) {
      // 设置正常状态下的拇指图像
      uiSlider.setThumbImageForState(UIImage.imageNamed(this.imageName), UIControlState.Normal);
      // 假设高亮状态图片以 '-down' 为后缀
      let imgParts = this.imageName.split('.');
      let downImg = `${imgParts[0]}-down.${imgParts[1]}`;
      uiSlider.setThumbImageForState(UIImage.imageNamed(downImg), UIControlState.Highlighted);
    } else {
      // 用于穿梭控制等不需要拇指的情况
      uiSlider.userInteractionEnabled = false;
      uiSlider.setThumbImageForState(UIImage.new(), UIControlState.Normal);
    }
  }
}

在组件模板中即可使用:

<Slider slim-slider="fader.png" ... ></Slider>

对于 Android 平台 (slider.directive.android.ts),实现会复杂一些,因为需要处理默认的颜色设置器对自定义图形的干扰:

import { Directive, ElementRef, Input } from '@angular/core';
import { getNativeApplication } from 'application';
// ... 其他导入和资源获取辅助函数

@Directive({
  selector: '[slim-slider]'
})
export class SlimSliderDirective {
  @Input('slim-slider') imageName: string;
  private _thumb: android.graphics.drawable.BitmapDrawable;

  constructor(private el: ElementRef) {
    // 关键:覆盖 NativeScript 滑块默认的颜色设置器,防止其将拇指染成蓝色
    el.nativeElement[(<any>slider).colorProperty.setNative] = function (v) {
      // 忽略此设置器
    };
  }

  ngAfterViewInit() {
    let seekBar = <android.widget.SeekBar>this.el.nativeElement.android;
    if (this.imageName) {
      // ... 加载图片并设置为拇指
      this._addThumbImg(seekBar);
    } else {
      // ... 禁用交互等处理
    }
  }

  private _addThumbImg(seekBar: android.widget.SeekBar) {
    if (!this._thumb) {
      // 从应用资源中加载图片
      let imgParts = this.imageName.split('.');
      let name = imgParts[0];
      const res = getResources();
      if (res) {
        const identifier: number = res.getIdentifier(name, 'drawable', getApplication().getPackageName());
        if (0 < identifier) {
          this._thumb = <android.graphics.drawable.BitmapDrawable>res.getDrawable(identifier);
        }
      }
    }
    if (this._thumb) {
      seekBar.setThumb(this._thumb);
      seekBar.getThumb().clearColorFilter(); // 清除可能存在的颜色过滤器
    }
  }
}

此外,为了自定义 Android 上波形显示的颜色,还需要在 App_Resources/Androidcolors.xml 文件中添加插件所需的具体颜色属性。

总结

本章我们学习了如何利用 ListView 的多个项模板来创建动态、丰富的用户界面,这适用于许多场景。对于数据持久化,我们探讨了在存储前序列化复杂数据、并在恢复时重新水合的重要性。最后,我们通过创建可重用的 Angular 指令进一步美化和定制了 UI 组件。

随着核心功能的完成,我们的应用已经具备了坚实的基础。在接下来的章节中,我们将通过集成 @ngrx/store@ngrx/effects 来改进应用的状态管理,构建更具可预测性和响应式的架构。虽然类似 Redux 的架构最好在项目初期规划,但后续集成同样能带来显著优势。

第十章:@ngrx/store + @ngrx/effects 用于状态管理

随着应用规模的增长,管理状态会变得复杂。我们希望应用行为具有可预测性,而掌控其状态是关键。状态可以定义为在特定时间点的特定条件。对于我们的应用,状态包括播放器是否正在播放、录音机是否在录音、音轨列表 UI 是否处于混音模式等。

将状态存储在单一位置使我们能够确切知道应用在任何时刻的状态。否则,状态通常会分散在不同的组件和服务中,导致功能扩展时出现多个不一致的状态版本,当这些功能需要交互时,问题会更加棘手。

理解 Redux 和 @ngrx/store

Redux 是一个用于 JavaScript 应用的可预测状态容器。其核心概念是:

  • 整个应用的状态存储在单一存储库的对象树中。
  • 改变状态树的唯一方法是发出一个描述发生了什么的操作。
  • 通过编写纯归约器来指定操作如何转换状态树。

所有状态都是不可变的,每个归约器都是一个纯函数(对于相同的输入总是返回相同的输出)。除了提高性能(对象引用比较比深度属性比较更快),不可变性还增强了代码的解耦和可维护性。

@ngrx/store 是一个受 Redux 启发、由 RxJS 驱动的 Angular 状态管理系统。它将 Observables 深度集成到 Redux 概念中,使得构建真正响应式的 UI 和应用成为可能。

设计状态模型

在集成 ngrx 之前,先思考应用状态的各个方面。以下是一个合理的起点:

  • CoreModule:
    • user: 用户相关状态
      • recentUsername: 最近成功登录的用户名
      • current: 当前认证的用户对象(如果存在)
  • MixerModule:
    • mixer: 混音相关状态
      • compositions: 用户保存的作品列表
      • activeComposition: 当前选中的作品
  • PlayerModule:
    • player: 播放器状态
      • playing: 是否正在播放
      • duration: 总时长
      • completed: 是否播放完毕
      • seeking: 是否正在跳转
  • RecorderModule:
    • recorder: 录音状态(例如,用一个枚举表示)
  • UI 状态 (不属于特定模块):
    • trackListViewType: 音轨列表当前活动的视图类型(如 'default' 或 'waveform')

状态模型很难在第一次就设计完美,可以随着应用演进逐步调整。

安装和集成 @ngrx/store

首先安装:

npm i @ngrx/store --save

我们将通过 StoreModule 为应用提供单一存储库。在 CoreModule 中定义初始状态(排除懒加载模块的状态),懒加载的功能模块将在需要时添加自己的状态和归约器。

定义核心用户状态

创建 user.state.ts 定义状态形状和初始状态:

export interface IUserState {
  recentUsername?: string;
  current?: any;
  loginCanceled?: boolean;
}
export const userInitialState: IUserState = {};

创建 user.action.ts 定义用户相关的操作:

import { Action } from '@ngrx/store';
import { IUserState } from '../states';

export namespace UserActions {
  const CATEGORY: string = 'User';

  export interface IUserActions {
    INIT: string;
    LOGIN: string;
    LOGIN_SUCCESS: string;
    LOGIN_CANCELED: string;
    LOGOUT: string;
    UPDATED: string;
  }
  export const ActionTypes: IUserActions = {
    INIT: `${CATEGORY} Init`,
    LOGIN: `${CATEGORY} Login`,
    LOGIN_SUCCESS: `${CATEGORY} Login Success`,
    LOGIN_CANCELED: `${CATEGORY} Login Canceled`,
    LOGOUT: `${CATEGORY} Logout`,
    UPDATED: `${CATEGORY} Updated`
  };

  export class InitAction implements Action { type = ActionTypes.INIT; payload = null; }
  export class LoginAction implements Action {
    type = ActionTypes.LOGIN;
    constructor(public payload: { msg: string; usernameAttempt?: string}) { }
  }
  export class LoginSuccessAction implements Action {
    type = ActionTypes.LOGIN_SUCCESS;
    constructor(public payload: any /*user object*/) { }
  }
  export class LoginCanceledAction implements Action {
    type = ActionTypes.LOGIN_CANCELED;
    constructor(public payload?: string /*last attempted username*/) { }
  }
  export class LogoutAction implements Action { type = ActionTypes.LOGOUT; payload = null; }
  export class UpdatedAction implements Action {
    type = ActionTypes.UPDATED;
    constructor(public payload: IUserState) { }
  }
  export type Actions = InitAction | LoginAction | LoginSuccessAction | LoginCanceledAction | LogoutAction | UpdatedAction;
}

创建 user.reducer.ts 纯函数来处理状态转换:

import { IUserState, userInitialState } from '../states/user.state';
import { UserActions } from '../actions/user.action';

export function userReducer(
  state: IUserState = userInitialState,
  action: UserActions.Actions
): IUserState {
  switch (action.type) {
    case UserActions.ActionTypes.UPDATED:
      return Object.assign({}, state, action.payload);
    default:
      return state;
  }
}

UPDATED 操作将是最终改变用户状态的操作。其他操作会触发一个链条,在需要时最终分发 UPDATED

安装和集成 @ngrx/effects

效果是操作的来源,允许我们在操作分发和最终改变状态之前插入应该发生的行为(如处理异步请求)。安装:

npm i @ngrx/effects --save

创建 user.effect.ts 来处理用户相关的副作用链条:

import { Injectable } from '@angular/core';
import { Store, Action } from '@ngrx/store';
import { Effect, Actions } from '@ngrx/effects';
import { Observable } from 'rxjs/Observable';
import { UserService } from '../services/user.service';
import { UserActions } from '../actions/user.action';
import { DatabaseService } from '../services/database.service';

@Injectable()
export class UserEffects {
  @Effect() init$: Observable<Action> = this.actions$
    .ofType(UserActions.ActionTypes.INIT)
    .startWith(new UserActions.InitAction()) // 应用启动时自动触发
    .map(action => {
      const current = this.databaseService.getItem(DatabaseService.KEYS.currentUser);
      const recentUsername = this.databaseService.getItem(DatabaseService.KEYS.recentUsername);
      return new UserActions.UpdatedAction({ current, recentUsername });
    });

  @Effect() login$: Observable<Action> = this.actions$
    .ofType(UserActions.ActionTypes.LOGIN)
    .withLatestFrom(this.store)
    .switchMap(([action, state]) => {
      const current = state.user.current;
      if (current) {
        // 用户已登录,直接触发更新
        return Observable.of(new UserActions.UpdatedAction({ current }));
      } else {
        return Observable.fromPromise(
          this.userService.promptLogin(action.payload.msg, action.payload.usernameAttempt)
        )
        .map(user => (new UserActions.LoginSuccessAction(user)))
        .catch(usernameAttempt => Observable.of(new UserActions.LoginCanceledAction(usernameAttempt)));
      }
    });

  @Effect() loginSuccess$: Observable<Action> = this.actions$
    .ofType(UserActions.ActionTypes.LOGIN_SUCCESS)
    .map((action) => {
      const user = action.payload;
      const recentUsername = user.username;
      this.databaseService.setItem(DatabaseService.KEYS.currentUser, user);
      this.databaseService.setItem(DatabaseService.KEYS.recentUsername, recentUsername);
      return new UserActions.UpdatedAction({ current: user, recentUsername, loginCanceled: false });
    });

  @Effect() loginCancel$ = this.actions$
    .ofType(UserActions.ActionTypes.LOGIN_CANCELED)
    .map(action => {
      const usernameAttempt = action.payload;
      if (usernameAttempt) {
        // 登录失败,重试
        return new UserActions.LoginAction({ msg: this._loginPromptMsg, usernameAttempt });
      } else {
        return new UserActions.UpdatedAction({ loginCanceled: true });
      }
    });

  @Effect() logout$: Observable<Action> = this.actions$
    .ofType(UserActions.ActionTypes.LOGOUT)
    .map(action => {
      this.databaseService.removeItem(DatabaseService.KEYS.currentUser);
      return new UserActions.UpdatedAction({ current: null });
    });

  private _loginPromptMsg: string;

  constructor(
    private store: Store<any>,
    private actions$: Actions,
    private databaseService: DatabaseService,
    private userService: UserService
  ) { }
}

效果链清晰地表达了数据流意图。UPDATED 操作没有关联的效果,它会直接进入归约器来更新状态。

现在,UserService 可以简化为只处理登录对话框的提示逻辑。在 CoreModule 中集成状态管理和效果:

import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { userReducer } from './reducers';
import { UserEffects } from './effects';

@NgModule({
  imports: [
    // ... 其他模块
    StoreModule.forRoot({ user: userReducer }), // 定义核心应用状态
    EffectsModule.forRoot([ UserEffects ]), // 注册核心效果
  ],
  // ... 提供者和导出
})
export class CoreModule { }

改进身份验证守卫

有了 ngrx,身份验证守卫可以变得更简洁:

import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Subscription } from 'rxjs/Subscription';
import { IUserState, UserActions } from '../modules/core';

@Injectable()
export class AuthGuard implements CanActivate, CanLoad {
  private _sub: Subscription;
  constructor(private store: Store<any>) { }

  canActivate(): Promise<boolean> {
    return new Promise((resolve, reject) => {
      this.store.dispatch(new UserActions.LoginAction({ msg: 'Authenticate to record.' }));
      this._sub = this.store.select(s => s.user).subscribe((state: IUserState) => {
        if (state.current) {
          this._reset();
          resolve(true);
        } else if (state.loginCanceled) {
          this._reset();
          resolve(false);
        }
      });
    });
  }
  // ... canLoad 和 _reset 方法
}

为懒加载的功能模块提供状态

对于懒加载的 MixerModule,可以使用 StoreModule.forFeature 来提供其专属的状态。首先在模块中定义:

import { StoreModule } from '@ngrx/store';

@NgModule({
  imports: [
    // ... 其他导入
    StoreModule.forFeature('mixerModule', {
      mixer: {} // 稍后添加归约器
    })
  ],
})
export class MixerModule { }

然后定义 MixerModule 的状态形状 (mixer.state.ts)、操作 (mixer.action.ts) 和归约器。其模式与用户状态类似,包括 INIT, ADD, EDIT, SAVE, SELECT, OPEN_RECORD, UPDATE, UPDATED 等操作。

通过这种方式,ngrx 帮助我们构建了一个更加清晰、可预测和响应式的应用架构,减少了组件和服务之间的直接依赖,使数据流更加明确和易于管理。
首先,在 app/modules/mixer/reducers/mixer.reducer.ts 中定义 mixerReducer:

import { MixerActions } from '../actions';

export function mixerReducer(
  state: IMixerState = mixerInitialState,
  action: MixerActions.Actions
): IMixerState {
  switch (action.type) {
    case MixerActions.ActionTypes.UPDATED:
      return Object.assign({}, state, action.payload);
    default:
      return state;
  }
}

接下来,在 app/modules/mixer/effects/mixer.effect.ts 中创建 MixerEffects:

// angular
import { Injectable, ViewContainerRef } from '@angular/core';

// nativescript
import { RouterExtensions } from 'nativescript-angular/router';

// libs
import { Store, Action } from '@ngrx/store';
import { Effect, Actions } from '@ngrx/effects';
import { Observable } from 'rxjs/Observable';

// module
import { CompositionModel } from '../../shared/models';
import { PlayerActions } from '../../player/actions';
import { RecordComponent } from '../../recorder/components/record.component';
import { MixerService } from '../services/mixer.service';
import { MixerActions } from '../actions';

@Injectable()
export class MixerEffects {
  @Effect() init$: Observable<Action> = this.actions$
    .ofType(MixerActions.ActionTypes.INIT)
    .startWith(new MixerActions.InitAction())
    .map(action =>
      new MixerActions.UpdatedAction({
        compositions: this.mixerService.hydrate(
          this.mixerService.savedCompositions() || this.mixerService.demoComposition()
        )
      })
    );

  @Effect() add$: Observable<Action> = this.actions$
    .ofType(MixerActions.ActionTypes.ADD)
    .withLatestFrom(this.store)
    .switchMap(([action, state]) =>
      Observable.fromPromise(this.mixerService.add())
        .map(value => {
          if (value.result) {
            let compositions = [...state.mixerModule.mixer.compositions];
            let composition = new CompositionModel({
              id: compositions.length + 1,
              name: value.text,
              order: compositions.length // next one in line
            });
            compositions.push(composition);
            // persist changes
            return new MixerActions.SaveAction(compositions);
          } else {
            return new MixerActions.CancelAction();
          }
        })
    );

  @Effect() edit$: Observable<Action> = this.actions$
    .ofType(MixerActions.ActionTypes.EDIT)
    .withLatestFrom(this.store)
    .switchMap(([action, state]) => {
      const composition = action.payload;
      return Observable.fromPromise(this.mixerService.edit(composition.name))
        .map(value => {
          if (value.result) {
            let compositions = [...state.mixerModule.mixer.compositions];
            for (let i = 0; i < compositions.length; i++) {
              if (compositions[i].id === composition.id) {
                compositions[i].name = value.text;
                break;
              }
            }
            // persist changes
            return new MixerActions.SaveAction(compositions);
          } else {
            return new MixerActions.CancelAction();
          }
        });
    });

  @Effect() update$: Observable<Action> = this.actions$
    .ofType(MixerActions.ActionTypes.UPDATE)
    .withLatestFrom(this.store)
    .map(([action, state]) => {
      let compositions = [...state.mixerModule.mixer.compositions];
      const composition = action.payload;
      for (let i = 0; i < compositions.length; i++) {
        if (compositions[i].id === composition.id) {
          compositions[i] = composition;
          break;
        }
      }
      // persist changes
      return new MixerActions.SaveAction(compositions);
    });

  @Effect() select$: Observable<Action> = this.actions$
    .ofType(MixerActions.ActionTypes.SELECT)
    .map(action => {
      this.router.navigate(['/mixer', action.payload.id]);
      return new MixerActions.UpdatedAction({
        activeComposition: action.payload
      });
    });

  @Effect({ dispatch: false }) openRecord$: Observable<Action> = this.actions$
    .ofType(MixerActions.ActionTypes.OPEN_RECORD)
    .withLatestFrom(this.store)
    // always pause/reset playback before handling
    .do(action => new PlayerActions.PauseAction(0))
    .map(([action, state]) => {
      if (state.mixerModule.mixer.activeComposition && state.mixerModule.mixer.activeComposition.tracks.length) {
        // show record modal but check authentication
        if (state.user.current) {
          if (action.payload.track) {
            // rerecording
            this.dialogService
              .confirm('Are you sure you want to re-record this track?')
              .then((ok) => {
                if (ok)
                  this._showRecordModal(action.payload.vcRef, action.payload.track);
              });
          } else {
            this._showRecordModal(action.payload.vcRef);
          }
        } else {
          this.store.dispatch(new UserActions.LoginToRecordAction(action.payload));
        }
      } else {
        // navigate to it
        this.router.navigate(['/record']);
      }
      return action;
    });

  @Effect() save$: Observable<Action> = this.actions$
    .ofType(MixerActions.ActionTypes.SAVE)
    .withLatestFrom(this.store)
    .map(([action, state]) => {
      const compositions = action.payload || state.mixerModule.mixer.compositions;
      // persist
      this.mixerService.save(compositions);
      return new MixerActions.UpdatedAction({ compositions });
    });

  constructor(
    private store: Store<any>,
    private actions$: Actions,
    private router: RouterExtensions,
    private dialogService: DialogService,
    private mixerService: MixerService
  ) { }

  private _showRecordModal(vcRef: ViewContainerRef, track?: TrackModel) {
    let context: any = { isModal: true };
    if (track) {
      // re-recording track
      context.track = track;
    }
    this.dialogService.openModal(
      RecordComponent,
      vcRef,
      context,
      './modules/recorder/recorder.module#RecorderModule'
    );
  }
}

在效果链中,openRecord$ 是最有趣的一个。我们使用 @Effect({ dispatch: false }) 来指示它不应在末尾分发任何动作,而是直接执行工作,例如检查用户认证或 activeComposition 是否包含轨道,从而有条件地在模态中打开记录视图或进行路由导航。我们使用了 .do 操作符来插入任意动作而不影响事件序列,确保在用户尝试打开记录视图时播放总是暂停。这个链展示了一些高级用法,为后续的 PlayerActions 奠定了基础。

通过这个效果链,我们可以简化 MixerService

@Injectable()
export class MixerService {
  public add() {
    return this.dialogService.prompt('Composition name:');
  }

  public edit(name: string) {
    return this.dialogService.prompt('Edit name:', name);
  }
}

我们将大部分结果处理逻辑移到了效果链中,使服务更简洁。您可以根据需要决定在服务中保留更多逻辑或保持效果链简单,但这展示了 ngrx 的灵活性。

为了完成懒加载状态处理,确保这些效果在 MixerModule 加载时运行:

// libs
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { MixerEffects } from './effects';
import { mixerReducer } from './reducers';

@NgModule({
  imports: [
    PlayerModule,
    SharedModule,
    NativeScriptRouterModule.forChild(routes),
    // mixer state
    StoreModule.forFeature('mixerModule', {
      mixer: mixerReducer
    }),
    // mixer effects
    EffectsModule.forFeature([
      MixerEffects
    ])
  ],
  ...
})
export class MixerModule { }

现在,组件处理得到了改善。从 app/modules/mixer/components/mixer.component.ts 开始:

export class MixerComponent implements OnInit, OnDestroy {
  constructor(private store: Store<any>, private vcRef: ViewContainerRef) { }

  ngOnInit() {
    this._sub = this.store.select(s => s.mixerModule.mixer)
      .subscribe((state: IMixerState) => {
        this.composition = state.activeComposition;
      });
  }

  public record(track?: TrackModel) {
    this.store.dispatch(new MixerActions.OpenRecordAction({
      vcRef: this.vcRef,
      track
    }));
  }

  ngOnDestroy() {
    this._sub.unsubscribe();
  }
}

ngOnInit 中,组件响应混合器状态,将组成设置为 activeComposition,确保它总是用户当前选中的组成。record 方法触发 OpenRecordAction,传递适当的 ViewContainerRef 和轨道信息(如果用户重新录制)。

接下来是 app/modules/mixer/components/mix-list.component.ts 的简化:

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

// libs
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';

// app
import { MixerActions } from '../actions';
import { IMixerState } from '../states';

@Component({
  moduleId: module.id,
  selector: 'mix-list',
  templateUrl: 'mix-list.component.html'
})
export class MixListComponent {
  public mixer$: Observable<IMixerState>;

  constructor(private store: Store<any>) {
    this.mixer$ = store.select(s => s.mixerModule.mixer);
  }

  public add() {
    this.store.dispatch(new MixerActions.AddAction());
  }

  public edit(composition) {
    this.store.dispatch(new MixerActions.EditAction(composition));
  }

  public select(composition) {
    this.store.dispatch(new MixerActions.SelectAction(composition));
  }
}

我们移除了 MixerService 注入,通过设置可观察状态 mixer$ 并集成 MixerActions 使组件响应式。这使组件更轻量、易于测试和维护,减少了对 MixerService 的显式依赖。在视图中,我们可以利用 Angular 的异步管道通过状态访问用户保存的组成:

<ActionBar title="Compositions" class="action-bar">
  <ActionItem (tap)="add()" ios.position="right">
    <Button text="New" class="action-item"></Button>
  </ActionItem>
</ActionBar>
<ListView [items]="(mixer$ | async)?.compositions | orderBy: 'order'" class="list-group">
  <ng-template let-composition="item">
    <GridLayout rows="auto" columns="100,*,auto" class="list-group-item">
      <Button text="Edit" (tap)="edit(composition)" row="0" col="0"></Button>
      <Label [text]="composition.name" (tap)="select(composition)" row="0" col="1" class="h2"></Label>
      <Label [text]="composition.tracks.length" row="0" col="2" class="text-right"></Label>
    </GridLayout>
  </ng-template>
</ListView>

Angular 的异步管道订阅可观察对象或 Promise,并返回最新值,当组件销毁时自动取消订阅,避免内存泄漏。这使我们能够创建高度可维护和灵活的响应式组件。

由于大部分内容都应用了相同的原则,为了避免冗长,我们建议探索代码仓库中的 ngrx 集成部分。通过实际代码和运行,您可以更深入地理解 ngrx 如何融入应用并带来优势。

社区贡献者如 Rob Wormald、Mike Ryan、Brian Troncone 和 Brandon Roberts 使 ngrx 的使用更加愉快,我们对此表示感谢。

摘要

希望您在整合 ngrx 的过程中看到了数据流简化和清晰化的模式。它通过为动作提供一致的效果链,减少了代码量并改善了数据流,无论模块是否懒加载。通过减少显式依赖注入的开销,转而依赖 Store 和 Actions 启动工作,我们提高了应用的可维护性和可扩展性。此外,这为有效的单元测试铺平了道路,将在第十二章中介绍。

本章强调了 NativeScript 与 Angular 结合的优势,通过集成如 ngrx 的丰富库来改善应用架构和数据流。

接下来,我们期待第十一章“使用 SASS 进行抛光”,对应用进行打磨,让它焕发光彩。

第十一章:使用 SASS 进行抛光

在上一章介绍了底层管道改进和 ngrx 状态管理后,现在终于到了抛光应用以改善外观和感觉的时候了。样式时机取决于开发流程,但本书中我们选择避免将 CSS 抛光与功能开发混合,以保持概念集中。现在,我们将集成 SASS 来帮助管理样式。

由于标准 CSS 在样式增长时难以维护,我们将使用社区插件 nativescript-dev-sass,由 Todd Anglin 开发。

本章将涵盖以下主题:

  • 将 SASS 集成到您的应用中
  • 构建核心主题 SASS 设置的最佳实践
  • 构建可扩展的样式设置,以最大化 iOS 和 Android 之间的样式重用
  • 使用字体图标,如 Font Awesome,通过 nativescript-ngx-fonticon 插件

SASS 是 CSS 的扩展语言,为基本语言增添了力量和优雅。它允许使用变量、嵌套规则、混入和内联导入等,同时保持与 CSS 的兼容性。SASS 有助于组织大型样式表并使小型样式表快速运行。

首先,安装社区插件:

npm install nativescript-dev-sass --save-dev

此插件在构建应用前自动将 SASS 编译为 CSS,无需额外构建工具。

我们建议以特定方式组织 SASS 源文件,以促进 iOS 和 Android 之间的共享样式,并允许平台特定覆盖。默认的 nativescript-theme-core 附带了一套组织良好的 SASS 源文件。

从重命名文件开始:

  • app.ios.css 改为 app.ios.scss
  • app.android.css 改为 app.android.scss

对于 app.ios.scss

@import 'style/common';
@import 'style/ios-overrides';

对于 app.android.scss

@import 'style/common';
@import 'style/android-overrides';

创建 style 文件夹,包含部分 SASS 文件。从变量开始:

  • style/_variables.scss
// baseline theme colors
@import '~nativescript-theme-core/scss/dark';
// define our own variables or simply override those from the light set here...

您可以根据不同皮肤构建样式表。对于本应用,我们将基于深色皮肤设置颜色。

创建公共共享 SASS 文件,放置大部分共享样式。将 common.css 中的内容迁移至此,然后删除原文件:

  • style/_common.scss
// customized variables
@import 'variables';
// theme standard rulesets
@import '~nativescript-theme-core/scss/index';
// all the styles we had created previously in common.css migrated into here:

.action-bar {
  background-color: #101B2E; // we can now convert this to a SASS variable
}

Page {
  background-color: #101B2E; // we can now convert this to a SASS variable
}

ListView {
  separator-color: transparent;
}

.track-name-float {
  color: RGBA(136, 135, 3, .5); // we can now convert this to a SASS variable
}

.slider.fader {
  background-color: #000; // we could actually use $black from core theme now
}

.list-group .muted {
  opacity: .2;
}

这使用了变量文件,允许我们从核心主题提供基线变量和自定义调整。

创建 Android 覆盖文件:

  • styles/_android-overrides.scss
@import '~nativescript-theme-core/scss/platforms/index.android';
// our custom Android overrides can go here if needed...

这导入核心主题的 Android 覆盖,并允许自定义覆盖。

为 iOS 创建类似文件:

  • styles/_ios-overrides.scss
@import '~nativescript-theme-core/scss/platforms/index.ios';
// our custom iOS overrides can go here if needed...

现在,将任何组件的 .css 文件转换为 .scss。例如,record.component.css 重命名为 record.component.scss。NativeScript SASS 插件会自动编译嵌套的 .scss 文件。

建议忽略所有 *.css 文件从 git 中,以避免合并冲突,因为 .css 文件在每次构建时通过 SASS 编译生成。将以下内容添加到 .gitignore

*.js
*.map
*.css
hooks
lib
node_modules
/platforms

在 VS Code 中隐藏 .js.css 文件,可以配置如下:

{
  "files.exclude": {
    "**/app/**/*.css": {
      "when": "$(basename).scss"
    },
    "**/app/**/*.js": {
      "when": "$(basename).ts"
    },
    "**/hooks": true,
    "**/node_modules": true,
    "platforms": true
  }
}

使用 nativescript-ngx-fonticon 插件使用字体图标

我们可以用清晰的图标替换文本标签。NativeScript 支持通过 Unicode 值使用自定义字体图标,但 Angular 插件 nativescript-ngx-fonticon 提供了一个管道,以便于使用字体名称。

安装插件:

npm install nativescript-ngx-fonticon --save

对于本应用,我们将使用 Font Awesome 图标。从官方网站下载包,复制 fontawesome-webfont.ttfapp/fonts 文件夹。NativeScript 在构建时查找此文件夹中的自定义字体。

css/font-awesome.css 复制到 app/assets 文件夹,并修改文件,移除实用类,只保留字体类名。

由于 git 忽略了 *.css 文件,添加例外:

*.js
*.map
*.css
!app/assets/font-awesome.css
hooks
lib
node_modules
/platforms

设置插件。修改 app/modules/core/core.module

import { TNSFontIconModule } from 'nativescript-ngx-fonticon';

@NgModule({
  imports: [
    ...MODULES,
    // font icons
    TNSFontIconModule.forRoot({
      'fa': './assets/font-awesome.css'
    }),
    ...
  ],
  ...
})
export class CoreModule { }

TNSFontIconService 依赖于根组件实例化。修改 app/app.component.ts

import { TNSFontIconService } from 'nativescript-ngx-fonticon';

@Component({
  moduleId: module.id,
  selector: 'my-app',
  templateUrl: 'app.component.html'
})
export class AppComponent {
  constructor(private fontIconService: TNSFontIconService) { ... }
}

确保 fonticon 管道对视图组件可访问,从 SharedModule 导入和导出模块:

import { TNSFontIconModule } from 'nativescript-ngx-fonticon';

@NgModule({
  imports: [
    NativeScriptModule,
    NativeScriptRouterModule,
    NativeScriptFormsModule,
    TNSFontIconModule
  ],
  exports: [
    ...,
    TNSFontIconModule, ...PIPES
  ]
})
export class SharedModule {}

定义使用 Font Awesome 的类。由于在 iOS 和 Android 之间共享,修改 app/style/_common.scss

.fa {
  font-family: 'FontAwesome', fontawesome-webfont;
  font-size: 25;
}

Android 使用字体文件名作为字体家族,而 iOS 使用实际字体名称。如果需要,可以将字体文件重命名为 FontAwesome.ttf 并使用 font-family: FontAwesome

现在,在应用中渲染图标。例如,修改 app/modules/mixer/components/mix-list.component.html

<ActionBar title="Compositions" class="action-bar">
  <ActionItem (tap)="add()" ios.position="right">
    <Button [text]="'fa-plus' | fonticon" class="fa action-item"></Button>
  </ActionItem>
</ActionBar>
<ListView [items]="(mixer$ | async)?.compositions | orderBy: 'order'" class="list-group">
  <ng-template let-composition="item">
    <GridLayout rows="auto" columns="100,*,auto" class="list-group-item">
      <Button [text]="'fa-pencil' | fonticon" (tap)="edit(composition)" row="0" col="0" class="fa"></Button>
      <Label [text]="composition.name" (tap)="select(composition)" row="0" col="1" class="h2"></Label>
      <Label [text]="composition.tracks.length" row="0" col="2" class="text-right"></Label>
    </GridLayout>
  </ng-template>
</ListView>

调整 ListView 背景颜色为黑色,使用 SASS 变量在 app/style/_common.scss 中:

.list-group {
  background-color: $black;

  .muted {
    opacity: .2;
  }
}

继续添加图标到其他组件。例如,在 app/modules/player/components/track-list/track-list.component.html 中:

<ListView #listview [items]="tracks | orderBy: 'order'" class="list-group" [itemTemplateSelector]="templateSelector">
  <ng-template let-track="item" nsTemplateKey="default">
    <GridLayout rows="auto" columns="60,*,30" class="list-group-item" [class.muted]="track.mute">
      <Button [text]="'fa-circle' | fonticon" (tap)="record(track)" row="0" col="0" class="fa c-ruby"></Button>
      <Label [text]="track.name" row="0" col="1" class="h2"></Label>
      <Label [text]="(track.mute ? 'fa-volume-off' : 'fa-volume-up') | fonticon" row="0" col="2" class="fa" (tap)="track.mute=!track.mute"></Label>
    </GridLayout>
  </ng-template>
  ...
</ListView>

使用核心主题的颜色类,如 c-ruby。改进自定义 ActionBar 模板:

<ActionBar [title]="title" class="action-bar">
  <ActionItem nsRouterLink="/mixer/home">
    <Button [text]="'fa-list-ul' | fonticon" class="fa action-item"></Button>
  </ActionItem>
  <ActionItem (tap)="toggleList()" ios.position="right">
    <Button [text]="((uiState$ | async)?.trackListViewType == 'default' ? 'fa-sliders' : 'fa-list') | fonticon" class="fa action-item"></Button>
  </ActionItem>
  <ActionItem (tap)="recordAction.next()" ios.position="right">
    <Button [text]="'fa-circle' | fonticon" class="fa c-ruby action-item"></Button>
  </ActionItem>
</ActionBar>

美化播放器控件在 app/modules/player/components/player-controls/player-controls.component.html 中:

<StackLayout row="1" col="0" class="controls">
  <shuttle-slider></shuttle-slider>
  <Button [text]="((playerState$ | async)?.player?.playing ? 'fa-pause' : 'fa-play') | fonticon" (tap)="togglePlay()" class="fa c-white t-30"></Button>
</StackLayout>

使用核心主题的辅助类,如 c-white 设置图标为白色,t-30 设置字体大小为 30。

app/modules/recorder/components/record.component.html 中抛光记录视图:

<ActionBar title="Record" icon="" class="action-bar">
  <NavigationButton visibility="collapsed"></NavigationButton>
  <ActionItem text="Cancel" ios.systemIcon="1" (tap)="cancel()"></ActionItem>
</ActionBar>
<FlexboxLayout class="record">
  <GridLayout rows="auto" columns="auto,*,auto" class="p-10" [visibility]="isModal ? 'visible' : 'collapsed'">
    <Button [text]="'fa-times' | fonticon" (tap)="cancel()" row="0" col="0" class="fa c-white"></Button>
  </GridLayout>
  <Waveform class="waveform" [model]="recorderService.model" type="mic" plotColor="yellow" fill="false" mirror="true" plotType="buffer"></Waveform>
  <StackLayout class="p-5">
    <FlexboxLayout class="controls">
      <Button [text]="'fa-backward' | fonticon" class="fa text-center" (tap)="recorderService.rewind()" [isEnabled]="state == recordState.readyToPlay || state == recordState.playing"></Button>
      <Button [text]="recordBtn | fonticon" class="fa record-btn text-center" (tap)="recorderService.toggleRecord()" [isEnabled]="state != recordState.playing" [class.is-recording]="state == recordState.recording"></Button>
      <Button [text]="playBtn | fonticon" class="fa text-center" (tap)="recorderService.togglePlay()" [isEnabled]="state == recordState.readyToPlay || state == recordState.playing"></Button>
    </FlexboxLayout>
    <FlexboxLayout class="controls bottom" [class.recording]="state == recordState.recording">
      <Button [text]="'fa-check' | fonticon" class="fa" [class.save-ready]="state == recordState.readyToPlay" [isEnabled]="state == recordState.readyToPlay" (tap)="recorderService.save()"></Button>
    </FlexboxLayout>
  </StackLayout>
</FlexboxLayout>

调整组件类处理 recordBtnplayBtn

export class RecordComponent implements OnInit, OnDestroy {
  public recordBtn: string = 'fa-circle';
  public playBtn: string = 'fa-play';
}

app/modules/recorder/components/record.component.scss 中添加样式:

@import '../../../style/variables';

.record {
  background-color: $slate;
  flex-direction: column;
  justify-content: space-around;
  align-items: stretch;
  align-content: center;
}

.record .waveform {
  background-color: transparent;
  order: 1;
  flex-grow: 1;
}

.controls {
  width: 100%;
  height: 200;
  flex-direction: row;
  flex-wrap: nowrap;
  justify-content: center;
  align-items: center;
  align-content: center;

  .fa {
    font-size: 40;
    color: $white;

    &.record-btn {
      font-size: 70;
      color: $ruby;
      margin: 0 50 0 50;

      &.is-recording {
        color: $white;
      }
    }
  }
}

.controls.bottom {
  height: 90;
  justify-content: flex-end;
}

.controls.bottom.recording {
  background-color: #B0342D;
}

.controls.bottom .fa {
  border-radius: 60;
  font-size: 30;
  height: 62;
  width: 62;
  padding: 2;
  margin: 0 10 0 0;
}

.controls.bottom .fa.save-ready {
  background-color: #42B03D;
}

.controls .btn {
  color: #fff;
}

.controls .btn[isEnabled=false] {
  background-color: transparent;
  color: #777;
}

收尾工作

用颜色最终确定应用样式。改变 ActionBar 基色以提供整体感觉。在 app/style/_variables.scss 中定义变量:

// baseline theme colors
@import '~nativescript-theme-core/scss/dark';

$slate: #150e0c;

// page
$background: $black;
// action-bar
$ab-background: $black;

摘要

在本章中,我们为应用的外观添加了抛光。通过安装 nativescript-dev-sass 插件,我们构建了 CSS 同时保持样式整洁。了解如何利用核心主题的 SASS 文件组织是获得灵活基础的关键。

我们还使用 nativescript-ngx-fonticon 插件在应用中利用字体图标,用简洁的图标替换文本标签。

下一章中,我们将探讨如何对关键特性进行单元测试,以保护代码库免受回归影响。

0 Answers