事情起源当初一个简单的截屏然后推流出去的工具,这个工具当初我用winform简单实现了下,然后因公司业余,添加许多程序包,需要自动管理这些程序包,包含下载更新上传等,以及与后台交互,学生老师提醒,自动开关闭程序,自动推流等等功能。
这些功能都有一些特点,大部分实现不能放在UI线程,但是各个功能对应中间状态都需要在UI上显示,导致相应函数不能分隔,回调也超多,需求几次急改后,导致后续需求引入很麻烦,并且当初这个程序并没考虑给普通用户使用,现在需要考虑放给普通用户,而winform这种事件驱动类型的,界面与上层逻辑深度结合,界面不利于调整。
综合上述考虑,我想选择一种数据驱动的UI界面,避免能在各个事件中维护状态变化与显示,特别是这些事件互相影响状态连动的时候,以及最好能跨平台显示功能,界面易美化,就算我不会设计,让会设计的人方便改动就行,根据这些让我找到了Angular,现在整个项目逻辑已经移植差不多了,也算是对Angular基本了解了点,一句话,太好用的,特别在这项目上,写起来太爽。
先看一下相应界面,我不会设计界面,完全不知界面设计的好与坏,欢迎吐槽。
主界面
程序包更新界面
不会界面设计,放上来是为了后面讲解相应后面相应功能里的对应代码。
先讲一下整体设计,主要分二个部分,一就是angular负责界面展示与后台webapi交互,二是如程序包的安装更新管理功能,截屏推流部分,中途我因为electron框架能访问本地文件,倒是想重构一次TS版程序包管理模块的,但是截屏推流里全是底层C++实现,可能需要用到wasm技术才行,这些可能拉长整个项目时间,综合考虑,还是先利用已经封装好的C#模块完成这些功能,然后angular与这部分模块通过signlar实现的websocket二边通信,故分为二个进程,Angular用于界面展示各种状态信息,显示实时状态更新,而本机后台进程提供程序包的安装与上传下载,根据用户对应时间段自动开关程序,自动截屏推流等功能,这二个进程electron框架里的渲染进程与主进程关系,如下图展示。
当然这种二个进程的方式也增加了些代码量,比如本机后台与angular通讯用的结构体,这些结构C#/TS都要写一份。
在这还是先吹一波Angular这个框架,就我这个项目使用中,我认为一些处理非常好的地方。
- 数据单向绑定,双向绑定的超简洁语法,以及类似ngIf,nfFor语法超方便界面根据需求展示与排版,并且无任何侵入,不需要你为了绑定要在对应方向或是属性上做任何处理,绑定错误定位也非常清晰,这个应该和TS强类型动态语言的特性有关,说实话,我现在都感觉UI上用C#/C++这类语言真是太硬了,如绑定这种一是有侵入以及各种限定,二是界面展示时一般要对数据二次处理才能方便展示,在这完全没这问题,拿到服务里的数据,界面直接使用,不过如果是js又太软了,逻辑一多,又乱又不好排查,TS这种算是刚刚好。
- 注册服务的设计,原来在Winform里用单例来做相关状态的保存,共享这些状态的公用方法,在这使用服务,组件用到就注册,在这我使用二个主要服务,一个是对接webApi里各种调用及相关状态,二是用singlar对接本机后台相关调用与状态,包含互相调用及通知。
- 文档齐全,并且内置各种规则方便易用,官方文件里的组件与模板这块,大部分UI各种处理都涉及到了,按照对应情况选择相应处理方式就行,可能要看要记的多点,不过只要按照规则来,更少的BUG,更容易排错。
- 桌面可以用electron调用本地功能,app可以联合nativescript,内置神器RXJS,web版联合CSS方便设计界面。
举个例子,主界面里,先从后台得到所有程序包的信息,然后发到本机后台,查找本机相应程序现在的版本以及服务器版本,然后再返回给angular统计并显示,这个过程中,所有操作全是异步的,可以看下如何利用RXJS用同步方式来写这些异步实现。
连接后台,通过webapi得到所有程序列表。
// 得到所有程序列表 getProgramList(): Observable<ProgramInfo[] | boolean> { const xtimestamp = this.getTimeStamp(); const headers = new HttpHeaders() .set('uid', this.uid) .set('timestamp', xtimestamp) .set('signature', this.getSignature()) .set('Accept', 'application/json') .set('Content-Type', 'application/json;charset=utf-8'); return this.http.post<ProgramListInfo>(this.apiName + '/api/xxxxxxx/', '', { headers }) .pipe( map(data => { if (data.code === 200 && data.data !== null) { this.programList = data.data; return data.data; } return false; })); }
连接本机后台,通过signalR得到程序列表具体信息,如本地版本,服务器版本这些然后返回给angular前端。
// 填充所有课件里的本地版本与服务器版本信息 getProgramList(programList: ProgramInfo[]): Observable<AppProgram[]> { return from(this.hubProxy.invoke('GetProgramList', programList) .then((programs: AppProgram[]) => { // 选择一部分可以展示数据出去 const appPrograms = new Array<AppProgram>(); this.allPrograms = []; programList.forEach(element => { programs.find((progarm) => { if (progarm.id === element.app_id) { if (!progarm.remoteVersion || progarm.remoteVersion.length === 0) { progarm.remoteVersion = '0.0.0.0'; } progarm.appName = element.app_name; // 可以更新/安装/启动的展示 if (progarm.launcherMode !== LauncherMode.inactive) { appPrograms.push(progarm); } this.allPrograms.push(progarm); } }); }); this.programs = appPrograms; return appPrograms; })); }
然后在组件里,组合这二个功能,得到当前程序列表里的所有信息以便在界面上展示。
getProgramList(): void { // 请求HTTP上所有课件列表 this.userService.getProgramList().subscribe( (programList: ProgramInfo[] | boolean) => { if (typeof programList === 'boolean') { return; } else { if (!this.signalrService.bHaveConnect) { return; } // 如果数据请求正确,通过signalR请求本机后台进程查找到所有课件信息 this.signalrService.getProgramList(programList) .subscribe(appProgramArray => { // 通过本机查找数据后 this.appPrograms = appProgramArray; this.installCount = this.appPrograms.filter(item => item.launcherMode === 1).length; this.updateCount = this.appPrograms.filter(item => item.launcherMode === 2).length; console.log('install count:' + this.installCount); console.log('update count:' + this.updateCount); if (this.bHaveLesson) { this.currentProgram = this.appPrograms?.find((p) => p.id === this.liveLesson.appId); } }); } }, error => this.userService.handleError(error)); }
Observable我觉得,你可以简单理解封装了一个函数回调,毕竟原来没有async/await时,一般想实现类似功能也是传入一个完成后需要做什么的函数,简单来说,上面得到程序包与得到本地程序包详细信息这二个步骤都返回的是一个函数,后面调用subscribe后意思才是执行相应函数,并在执行完这个函数后再执行subscribe里的函数,这种异步方式简单理解成一个函数链,Observable里管理根据函数执行结果选择继续执行挂在它后面回调函数。
至于界面,简单的界面逻辑一般直接写在模板页面里,配合网页的流式布局很适合根据需求显示数据,如上面的更新程序界面,进度条的整个隐藏显示,进度,提示信息简单明了的实现。
<div fxLayout="column" fxLayoutGap="1em"> <div fxLayout="row" fxLayoutGap="1em"> <button mat-raised-button (click)="runPrograme()">{{getRunName()}}</button> <button *ngIf="program.launcherMode === 2" mat-raised-button (click)="forceRunPrograme()">强制运行</button> <button mat-raised-button (click)="openProgramPath()">打开目录</button> <button mat-raised-button (click)="verifyProgram()">验证文件完整</button> </div> <div fxLayout="column" fxLayoutGap="1em" *ngIf='bDownUpdate'> <div> 当前文件: {{fileArgs.Message}} <mat-divider></mat-divider> <mat-progress-bar mode="determinate" [value]="fileArgs.Current*100/fileArgs.All"></mat-progress-bar> </div> <div> 总进度: {{fileListArgs.Current}}/{{fileListArgs.All}} <mat-divider></mat-divider> <mat-progress-bar mode="determinate" [value]="fileListArgs.Current*100/fileListArgs.All"> </mat-progress-bar> </div> </div> </div>
最后说下排版,现在网页布局一般用flax-layout布局,http://flexboxfroggy.com/ 花不到半个小时做完,你就掌握的差不多了。
angular有个包装的模块angular/flex-layout,搞清楚Angular flex-layout,一定要理解css 里 flex-layout概念,国内关于Angular flex-layout的说明是不准确的,特别是对自身生效,对子元素生效这种描述,如主界面中,把设置放右边,按这描述,整体排列我用gdLayoutAlign,设置这个选项用gdFlexAlign,一直没效果,我就把原始CSS里相关flex-layout各种概念理清了下,然后F12对应angular 里的flex-layout各种对象,其实很明显,我就是要个justify-content:flex-end的效果,对应的还是gdLayoutAlign。
上面主界面中,对应不同条件排版,比如没有开启程序打开推流,就从上向下全铺满,有就二二分部排列,如下就能满足,可以说是非常方便。
[gdAreas.gt-sm]="(condition) ? 'header header | cont1 cont2 | cont3 cont4| footer footer':'header | cont1 | cont2 | cont3 | cont4 | footer'"
总的来说,整个项目完成下来,都很顺利,可能需要注意的,signalr有二个版本,一个对应.net CORE,一个对应.net foramework,对应的angular的二个实现,常用的对应的是.net core版的,像这个项目本机后台用.net framewrok加owin搭建的,需要用另一版本,还有开发跨域问题,使用代理是最简单的方法,angular里有集成webpack,在相应文件里配置一下就行。服务里相关事件一般推荐为Subject来实现,不推荐用EventEmitter来实现,这个在关联组件里使用。
当然最大问题还是与现有的C++或是C#链接库交互的问题,现在这种二个进程的方法限制太大,毕竟前面研究的底层渲染,图形处理,多媒体处理大部分是C++所写,如果用C#,写个供C#使用的C++转C导出的链接层非常容易,而与js/ts的交互,现还只找到wasm技术能处理相关问题,以及electron/node.js内部有相关实现,还没开始研究,感觉如果这个问题能很好解决的话,我以后大部分界面都可以用这种方式来实现。