原文
Sharing code on iOS, Android and Web
跨平台應用開發有很長的一段歷史,但在AppTree,我們堅持使用原生開發來做我們重要的應用。
在2016年初,在我們的用戶手上我們有Android(Kotlin)和iOS(Swift) App。但我們的使用者需要在Web上使用我們的應用,所以我們著手用Dart做一個。
現在,在與原生iOS與Android開發保持長期關係之後,我們決定結束它。我們遇到一個新的做法,叫做Flutter。
那裡有很多使程式碼可重複使用的策略,大部分都是著重在相同App或專案中重複使用程式碼。
但client端的程式重複使用是更複雜的。一個應用需要分享到三個大的client端:Android、iOS和Web。這種類型的重複使用需要三件事情:
這使得Flutter跟Web應用的controller能夠知道view有甚麼樣的API可以呼叫。
Controller-View關係有不同的模式,但通常和controller是一對一關係。Views可以獨立於controller(簡單),或把它們的controller當做Presenter(聰明)。
注意:有些使用情境不適合使用這種分層架構。 例如,地圖。讓Views(在Flutter或Web實作)負責顯示地圖的服務更有意義。在Flutter,它將是使用Plugin (package:map_view)而Web是HTML函式庫(package:google_maps)。
然後找到罪魁禍首就會變得很容易
藉由創造一個特定平台依賴的AppController,現在每一個進入點都有一個定義良好的API。Flutter應用將import且注入FlutterService而Web應用將import且注入BrowserService。
在flutter每一個view訪問controller的API去通知controller使用者正在與應用互動:
startByCreating 和startByJoining 有不同的實作,導致 Controller (頁面)的狀態更新也有可能發出請求(requests)。
值得一提的是,這種關係可以有不同的實作:
希望你帶走知識的同時,也能留下短短一兩句的心得與我們分享
Sharing code on iOS, Android and Web
跨平台應用開發有很長的一段歷史,但在AppTree,我們堅持使用原生開發來做我們重要的應用。
在2016年初,在我們的用戶手上我們有Android(Kotlin)和iOS(Swift) App。但我們的使用者需要在Web上使用我們的應用,所以我們著手用Dart做一個。
現在,在與原生iOS與Android開發保持長期關係之後,我們決定結束它。我們遇到一個新的做法,叫做Flutter。
那裡有很多使程式碼可重複使用的策略,大部分都是著重在相同App或專案中重複使用程式碼。
但client端的程式重複使用是更複雜的。一個應用需要分享到三個大的client端:Android、iOS和Web。這種類型的重複使用需要三件事情:
- 一種可以在三種平台上運行的語言
- 為每個平台建立可用,專業和美觀介面的工具
- 在不同介面共用重要程式碼的好策略
在Android和iOS之間,Flutter已經證明它可以解決兩者的差距。但Web跟Mobile是非常不同的。像可訪問性,XHR和其他只有web有的api都是很大的不同。到頭來Web開發者為了良好的使用者體驗,仍然需要去擁抱平台。
在AppTree,我們在三個平台共用了70%的程式碼。這些是我們使用的策略。他們是簡單的規則,但不一定容易實現。這篇文章的重點將著重在三個規則:
- 使用層(Layers)
- 簡單的Views
- 注入依賴
使用層(Layers)
層是非常常見的技術,用來分解複雜的應用。在client應用的使用情境中,Views與Services和應用的其他部分是解耦的:
在你的應用中使用層 |
每一層透過函式庫解耦其他層。例如,Services層沒有匯入controller,他的工作只是提供更高層次的Api。這概念是層只能呼叫它下面的層。
Services通常是跟遠端Server或平台互動的類別。Firebase,JSON/HTTP Services,Websockets,檔案系統存取等,都是services。
Controller層包含你應用重要的領域相關(業務邏輯)程式碼。它不一定要用一個controller類別。事實上這層完全可以被其他模式(Redux, Flux, 或任何 *Ux.)取代。重要的是將Views和Services視為抽象介面。
Views是定義元件(Flutter)和組件(Web)的地方,通常controller用來跟實做抽象介面的元件(Flutter)和組件(Web)互動:
Services通常是跟遠端Server或平台互動的類別。Firebase,JSON/HTTP Services,Websockets,檔案系統存取等,都是services。
Controller層包含你應用重要的領域相關(業務邏輯)程式碼。它不一定要用一個controller類別。事實上這層完全可以被其他模式(Redux, Flux, 或任何 *Ux.)取代。重要的是將Views和Services視為抽象介面。
Views是定義元件(Flutter)和組件(Web)的地方,通常controller用來跟實做抽象介面的元件(Flutter)和組件(Web)互動:
1 2 3 4 | abstract class MyView { Stream<Event> get onEvent; void render(State state); } |
這使得Flutter跟Web應用的controller能夠知道view有甚麼樣的API可以呼叫。
Controller-View關係有不同的模式,但通常和controller是一對一關係。Views可以獨立於controller(簡單),或把它們的controller當做Presenter(聰明)。
注意:有些使用情境不適合使用這種分層架構。 例如,地圖。讓Views(在Flutter或Web實作)負責顯示地圖的服務更有意義。在Flutter,它將是使用Plugin (package:map_view)而Web是HTML函式庫(package:google_maps)。
移除View邏輯
傳統上,views是平台限定的。即使你只是建立一個Flutter應用,你可能想要:
- 快速的單元測試
- 建立跟服務互動的命令行( command-line)工具
- 一個簡潔的函式庫和套件結構
一旦重要的程式碼被移出,就可以運行測試。
有時候,特定平台的函式庫意外的被移進controller層。快速診斷此問題的策略就是寫一個只能在VM上跑的測試:
有時候,特定平台的函式庫意外的被移進controller層。快速診斷此問題的策略就是寫一個只能在VM上跑的測試:
1 2 3 4 5 6 7 8 9 10 11 | @TestOn("vm") library controller_test; import 'package:myapp/controllers.dart'; import 'package:test/test.dart'; void main() { group("controller", () { test("is pure dart", () { var controller = new AppController(); }); }); } |
然後找到罪魁禍首就會變得很容易
注入依賴
Services可能需要在不同的平台有不同的實現。假設我們有個service和cache:
1 2 3 4 5 6 7 8 9 10 11 12 13 | abstract class MyService { Future<MyData> fetchData(); } abstract class MyCache { void cacheData(MyData data); } class AppController { final MyService _service; final MyCache _cache; AppController(this._service, this._cache); //... } |
藉由創造一個特定平台依賴的AppController,現在每一個進入點都有一個定義良好的API。Flutter應用將import且注入FlutterService而Web應用將import且注入BrowserService。
範例
在AppTree,我們替企業問題建立mobile與web的解決方案。我們的使用者有漂亮的CRM應用,讓他們完成每天的工作像史丹佛大學...等等。但會有個問題浪費他們很多的時間:中午在哪裡吃午餐。
我們投票
我們的解決方案是一個應用叫"我們投票"。它允許任何人建立一個選舉與提交他們認為應該獲勝的點子。一旦創建者關閉投票,它會顯示勝利者。它將顯示不同的頁面:
1 2 3 4 5 6 7 8 9 10 | enum Page { home, // create or join create, // enter topic joining, // enter id (skipped if using create) username, // enter username ideaSubmission, // enter an candidate ballot, // display each candidate (idea) and pick one waitingForVotes, // wait for the polls to close result, // show the winner } |
程式碼組織
我們的專案需要三個套件:
- letsvote:所有與平台無關的程式碼(controllers, models, services, interfaces)放在這
- letsvote_mobile
- letsvote_web:web應用,包括Dart伺服器託管。伺服器包括給web和mobile應用使用的REST API
web和mobile應用都使用pubspec.yaml中的關鍵字path導入套件:
1 2 3 | dependencies: letsvote: path: ../letsvote |
基本類型
首先,我們需要定義一些基本類型:- 選舉:父類別包含選舉與選民的所有訊息
- 選民:在選舉中具有唯一名稱的使用者
- 想法:選民可以投票的東西
每個類別可以使用JsonSerializable 去自動產生toJson()方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | @JsonSerializable() class Election extends _$ElectionSerializerMixin { final String id; final String topic; final List<Voter> voters; final List<Idea> ideas; bool pollsOpen; Election(this.id, this.topic, this.voters, this.ideas, this.pollsOpen); factory Election.fromJson(json) => _$ElectionFromJson(json); } @JsonSerializable() class Voter extends _$VoterSerializerMixin { final String name; Voter(this.name); factory Voter.fromJson(json) => _$VoterFromJson(json); } @JsonSerializable() class Idea extends _$IdeaSerializerMixin { final String name; final String authorName; int votes; Idea(this.name, this.authorName, this.votes); factory Idea.fromJson(json) => _$IdeaFromJson(json); } |
HTTP API
我們的伺服器API是使用shelf建立的 REST api:
1 2 3 4 5 6 7 8 | void configureRoutes(Router router) { router.post("/election", handleCreate); router.get("/election/{id}", handleGet); router.post("/election/{id}/user", handleJoin); router.post("/election/{id}/idea", handleSubmitIdea); router.post("/election/{id}/vote", handleVote); router.put("/election/{id}/close", handleClose); } |
Services層
請求被定義在package:http的請求函式庫中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | import 'dart:convert'; import 'package:http/http.dart'; import 'package:letsvote/model.dart'; class JsonRequest extends Request { JsonRequest(String method, Uri uri) : super(method, uri) { this.headers['Content-Type'] = 'application/json'; } } class CreateElection extends JsonRequest { CreateElection(Uri host, String topic) : super('POST', _createUri(host)) { body = JSON.encode(new CreateElectionRequest(topic)); } static Uri _createUri(Uri host) { return host.replace(path: 'election'); } } |
1 2 3 4 5 6 | // letsvote/lib/services.dart Future<Election> create(String topic) async { var request = new requests.CreateElection(host, topic); var response = await requester.send(request); return new Election.fromJson(JSON.decode(response.body)); } |
Controller層
我們的應用需要AppController去管理當下使用者的狀態。它需要儲存:
- 最後的選舉狀態 (the Election class)
- 當下的頁面
- 當下選民的唯一名字
- 在狀態改變時更新抽象的AppView
這層式使用MVP的風格建構,但可以使用Redux, Flux或其他模式去管理狀態與更新畫面。為了連接它們的關係,我們使用AppController.init():
View層
在這個例子,我們的View實現了對controller的訪問,並且可以訪問它的公開API來確定它應該如何渲染。
1 2 3 4 5 6 | Future init(AppView view) async { // ... _view = view; _view.controller = this; // ... } |
在flutter每一個view訪問controller的API去通知controller使用者正在與應用互動:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | class HomePage extends StatelessWidget { final AppController _controller; HomePage(this._controller); Widget build(BuildContext context) { return new Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ new Text("Let's Vote!"), new Text("Create a new vote or join an existing vote"), new MaterialButton( child: new Text("Create"), onPressed: () => _controller.startByCreating(), ), new MaterialButton( child: new Text("Join"), onPressed: () => _controller.startByJoining(), ), ], ); } } |
startByCreating 和startByJoining 有不同的實作,導致 Controller (頁面)的狀態更新也有可能發出請求(requests)。
值得一提的是,這種關係可以有不同的實作:
- 每個View可以有一個controller的抽象介面
- 每一個View可以在controller監聽的Stream 發出事件
應用
結語
我們發現使用層、簡化Views和注入依賴可以讓我們在Flutter和web共用大部分的程式碼。我們計算在三個平台共用了70%的程式碼(包含單元測試)。但這不是共用的數量,而是共用的內容。我們的應用共用重要的程式碼,我們應用的大腦。而不是有的應用超前或有的應用落後,所有應用都是最新的。每個人都能專注在新功能與改進,而不是在另一個平台做一樣的功能。
譯者補充
有些人對層可能比較沒概念,其實層就是一個封裝起來的物件,這部分可以搜尋關鍵字"MVP架構"會有更多的介紹,也可以參考我在Mopcon2016的簡報"跨平台開發從測試到架構",是在講如何三個平台上共用Java程式碼,概念本質上是一樣的,只是使用的工具不同。希望你帶走知識的同時,也能留下短短一兩句的心得與我們分享
留言
張貼留言
有什麼想法歡迎跟我們分享