事情起源當初一個簡單的截圖然後推流出去的工具,這個工具當初我用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內部有相關實現,還沒開始研究,感覺如果這個問題能很好解決的話,我以後大部分介面都可以用這種方式來實現。