在iOS,Android和Web上共用程式碼

原文
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。這種類型的重複使用需要三件事情:

  1. 一種可以在三種平台上運行的語言
  2. 為每個平台建立可用,專業和美觀介面的工具
  3. 在不同介面共用重要程式碼的好策略
在Android和iOS之間,Flutter已經證明它可以解決兩者的差距。但Web跟Mobile是非常不同的。像可訪問性,XHR和其他只有web有的api都是很大的不同。到頭來Web開發者為了良好的使用者體驗,仍然需要去擁抱平台。

在AppTree,我們在三個平台共用了70%的程式碼。這些是我們使用的策略。他們是簡單的規則,但不一定容易實現。這篇文章的重點將著重在三個規則:

  1. 使用層(Layers)
  2. 簡單的Views
  3. 注入依賴

使用層(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)互動:


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應用,你可能想要:
  1. 快速的單元測試
  2. 建立跟服務互動的命令行( command-line)工具
  3. 一個簡潔的函式庫和套件結構
一旦重要的程式碼被移出,就可以運行測試。
有時候,特定平台的函式庫意外的被移進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
}

程式碼組織

我們的專案需要三個套件:
  1. letsvote:所有與平台無關的程式碼(controllers, models, services, interfaces)放在這
  2. letsvote_mobile
  3. 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');
  }
}

Dart client為每個請求與回應提供類型。每個請求透過Requester發送(pub package):



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去管理當下使用者的狀態。它需要儲存:
  1. 最後的選舉狀態 (the Election class)
  2. 當下的頁面
  3. 當下選民的唯一名字
  4. 在狀態改變時更新抽象的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)。

值得一提的是,這種關係可以有不同的實作:

  1. 每個View可以有一個controller的抽象介面
  2. 每一個View可以在controller監聽的Stream 發出事件

應用


結語

我們發現使用層、簡化Views和注入依賴可以讓我們在Flutter和web共用大部分的程式碼。我們計算在三個平台共用了70%的程式碼(包含單元測試)。但這不是共用的數量,而是共用的內容。我們的應用共用重要的程式碼,我們應用的大腦。而不是有的應用超前或有的應用落後,所有應用都是最新的。每個人都能專注在新功能與改進,而不是在另一個平台做一樣的功能。



譯者補充

有些人對層可能比較沒概念,其實層就是一個封裝起來的物件,這部分可以搜尋關鍵字"MVP架構"會有更多的介紹,也可以參考我在Mopcon2016的簡報"跨平台開發從測試到架構",是在講如何三個平台上共用Java程式碼,概念本質上是一樣的,只是使用的工具不同。


希望你帶走知識的同時,也能留下短短一兩句的心得與我們分享

留言