第九章:赋能你的视图
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-template 的 nsTemplateKey 属性值匹配。
接下来,在 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 的增强将在第十章介绍。
现在,在 MixerModule 的 ActionBarComponent 中添加一个按钮来切换视图。修改对应的模板文件(例如 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.png 和 fader-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/Android 的 colors.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.ttf 到 app/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>
调整组件类处理 recordBtn 和 playBtn:
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 插件在应用中利用字体图标,用简洁的图标替换文本标签。
下一章中,我们将探讨如何对关键特性进行单元测试,以保护代码库免受回归影响。