iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 10
1
Mobile Development

Why Flutter why? 從表層到底層,從如何到為何。系列 第 10

days[9] = "為什麼需要依賴注入?(下)"

或:為什麼你以為的依賴注入可能不是依賴注入,如果你是從Flutter社群學到這個詞的話...


上一篇我們以MusicRecommender為例,說明了若在類別A中直接依賴另一個類別B的實作,在程式開發的幾個實際場景中可能會遇到的問題,以及類別B的介面宣告和實作如何能夠幫助你解決這些問題。然而我們其實沒有提到的是,類別A到底要怎麼使用類別B的介面和實作?

這是因為上一篇聊的DIP和IoC都只是一個原則,其中DIP已經靠著宣告介面來實現了,那麼IoC又該如何實現呢?其中一種方式正是...依賴注入!

class MusicRecommender {
  final MusicRepository repo;

  MusicRecommender(this.repo); // This is DI

  List<Music> recommendMusic(User user) {
    return repo.getMusics()
        .where((music) =>  user.taste.contains(music.genre))
        .toList();
  }
}
abstract class MusicRepository {
  List<Music> getMusics();
}
class FirebaseMusicRepository implement MusicRepository {
  List<Music> getMusics() => // get music from Firebase
}

因為MusicRepository現在只是個介面,我們顯然沒辦法在MusicRecommender內實例化一個介面。但如果我們直接在MusicRecommender內實例化FirebaseMusicRepository,介面就失去意義了。所以我們能做的就是在MusicRecommender內宣告我們需要某種MusicRepository,然後把MusicRepository的實作傳入。

上面的例子是從constructor傳入,但我們也可以透過一個setter傳入:

class MusicRecommender {
  MusicRepository repo;
  void setRepo(MusicRepository repo) => this.repo = repo;
}

有人認為它的優點是

  1. 比較有彈性,依賴變成是可選的,例如同一個MusicRecommender在不同場景使用時可能只需要一部分的依賴。
  2. 當依賴很多時,可以避免constructor過於雜亂。

至於缺點則是

  1. 無法限制依賴不會在初始化後改變(注意到MusicRepository repo;少了final了嗎?)。
  2. 依賴關係較分散,不像集中在constructor那麼一目了然。

不過話說回來,那兩個優點真的是優點嗎?如果發生了那種情況,是不是表示你應該稍微分離一下這個類別的責任了呢?因此,雖然很多DI框架都支援constructor injection和setter injection,但實務上幾乎都是比較偏好constructor injection。

總之這就是DI了。還記得我們上一篇開頭說的嗎?「依賴注入就是為了使程式更有彈性,把class A需要的class B從外面傳入」。把這句話裡的「需要」和「傳入」換成比較潮的用詞「依賴」和「注入」,你就得到它了。

好接下來你可能會想「如果DI就這麼簡單這麼平凡,為什麼我們還需要談論它?為什麼還需要套件?」這是因為,雖然我們可能會在某些地方不自覺地使用了被稱作DI的模式,但是當我們試著盡可能在所有地方都套用DI時,問題就來了。

好我不能在類別A內實例化類別B,那我要在哪裡實例化?類別A上層的類別C嗎?

class C {
  C() {
    B b = B();
    a = A(b);
  }
  A a;
}
class A {
  A(this.b);
  B b;
}
class B {}

這樣反而更奇怪,C原本只依賴A,現在變得也依賴不相干的B了,更何況不論是A還是B,都不該在C裡面實例化不是嗎?那難道要在上一層的D...

畢竟我們是物件導向,幾乎所有東西都是物件,當我們把所有物件實例化的程式碼從類別定義中全部拉出來,不斷往上提昇,最後的終點就是...main()

void main() {
  A a = A();
  B b = B(a);
  C c = C();
  D d = D(c, b);
  E e = E(d, a)
}

當然其實不一定要是main,任何一個程式啟動時,足夠早的時間點都可以。你可能會說「在啟動時實例化所有物件!?這樣啟動會很慢吧?」沒錯,所以我們其實只是宣告了A, B, C, D, E的建造函式

class IocContainer {
    A buildA() => A();
    B buildB() => B(buildA());
    C buildC() => C();
    D buildD() => D(buildC(), buildB());
    E buildE() => E(buildD(), buildA());
}

把這些建造函式全部移到某個專職生產所有物件的類別,然後再加上一些cache的機制(畢竟你不一定想每次都重建這些物件),我們就得到了一個自己手工建立的IoC Container。我們可以把這個IoC Container設成全域變數,那麼當程式中其它並非由自己建立的物件(Widget)須要任何物件時,就可以輕易的取得。

希望你不會看到全域變數就急的跳腳,因為我們會使用IoC Container來管理的類別大多都會是無狀態的。而UI層產生的各種狀態,自然有UI層的狀態管理來負責。畫張圖可能會更容易理解:
https://ithelp.ithome.com.tw/upload/images/20200910/20129053LnIa11aXTk.png
這只是個簡單的示意圖,不完全代表真實專案裡的依賴結構,重點只在於上層IoC Container內的類別都會是無狀態的,而狀態都會交由UI層的Widget和ViewModel管理(這裡以MVVM為例,可以替換成任何狀態管理)

回到上面的範例,我們自己手刻了一個IoC Container。接下來我們覺得buildX()寫五次太麻煩了,如果要寫50次甚至500次,那還不起笑。於是聰明的我們當然加入了泛型的機制:

void main() {
  final container = IocContainer();
  container.map<A>((container) => AImpl());
  container.map<B>((container) => B(container.get<A>())); // do this;
  container.map<B>((container) => B(AImpl())); // not this;
  container.map<C>((container) => C(), cache: true);
  ....
}
void somewhereElse() {
  B b = container.get<B>(); // recreated
  C c = container.get<C>() // get from cache
}

這裡的map是從一個型別映射到一個建造函數,這個函數的輸入是我們的container,輸出是該型別的一個實例。概念上就是我們告訴container「嘿,當我須要一個Type A的物件時,請幫我建立一個AImpl()」「嘿,當我須要一個Type B的物件時,請幫我建立一個B()。B須要一個Type A的物件,你知道該怎麼做了吧?」

這樣的type mapping,或是說type register,和基本的cache機制,就是DI套件能幫你實作的最簡單的功能之一。例如flutter_simple_dependency_injection基本上功能就只有這樣,不愧是flutter_simple_dependency_injection。其它所有DI套件都能做到類似的事,雖然API和實作各有不同,但概念都大同小異。

不過,從上面的範例中你也可以看到,我們要寫的程式碼其實並沒有精簡多少,不如說反而還變複雜了。一方面是通常我們若想要在使用一個container的時候方便,代價就是一開始的設定會比較複雜,這是一個非常常見的trade-off。另一方面是,整個DI最麻煩的地方其實還是在那些建造函數上。這些函數雖然麻煩,但其實都是蠻無腦的boilerplate code,只要瞄一眼constructor就能寫出來了。所以...有沒有可能自動產生這些函數呢?

當然可以,例如kiwi_generator

abstract class Injector {  
  @Register.singleton(ServiceA)
  @Register.factory(Service, from: ServiceB)
  @Register.factory(ServiceB, name: 'factoryB')
  @Register.factory(ServiceC, resolvers: {ServiceB: 'factoryB'})
  void configure();
}

或是更簡潔的injectable

@injectable
class ServiceA {}

@injectable
class ServiceB {
    ServiceB(ServiceA serviceA);
}

有人喜歡把整個Dependency Tree寫出來放在一起,比較有種一目了然的感覺。有人喜歡越簡單越好,最好只需要下一個annotaion,DI套件就可以把所有建造函數都產生出來。無論如何,這才是DI套件能幫你解決的,因為做了DI才產生的,最主要的問題。

相信現在你已經瞭解了

  • 什麼是DI?
  • 為什麼需要DI?
  • DI套件做了什麼?
  • 為了解決什麼問題?

最後就讓我們來看看,在現在Flutter社群的一部分人心中,Dependency Injection代表著什麼意義吧:
"Flutter Dependency Injection a Beginners Guide"

Motivation

Passing dependencies through a constructor is perfectly fine for accessing data one level down, maybe even two. What if you're four levels deep in the widget tree and you suddenly need the data from an object in your code? Imagine these are all widgets in separate files with their own logic. HomeView -> MyCustomList -> PostItem -> PostMenu -> PostActions -> LikeButton

"What is Dependency Injection in the context of Flutter development?"

In the Flutter world, DI is being able to pass a value deep down the widget tree without using globals.

This is all about that BuildContext object, mostly combined with Inheritedwidget.

簡單來說,這些人認為所謂DI要解決的問題就是,讓你能夠把上層提供的依賴,注入到好幾層深的Widget中。雖然這裡依賴注入這四個字用得好像很通順,但是你有注意到他們描述的這個問題,跟我們整整兩篇在講的問題,幾乎完全沒有關係嗎?事實上,他們描述的這個多層傳遞參數的問題,是個因為是Flutter才有的問題,而不是因為做了DI才產生的問題。的確,如果依照這個新的Flutter專屬的DI定義,那麼Provider可以是DI套件,Get可以是DI套件,InheritedWidget也可以是DI套件了。

然而,DI這個詞被誤用的情形還不只如此,另外還有一系列的套件被(作者自己或使用者)認為是DI,但其實是另一個同樣可以實現IoC的模式:Service Locator,諸如getddiinjectorio。它和DI想解決的是同一個問題,只是實作上有一些微妙但重要的不同。有些人強烈反對Service Locator到認為它是反模式,但也有人認為有那麼嚴重嗎?我的問題有解決就好了。

到底Dependency Injection和Service Locator的差別在哪裡?它們之間的優缺點又是什麼?這也是值得另開一篇文章探討的問題。而且我認為,搞混這兩者其實沒有像搞混上上一段那兩種那麼嚴重。既然這前後兩篇的主題是「為什麼需要依賴注入?」,而我相信我們也已經得到了非常清楚的答案,就讓我們在此告一段落吧。


上一篇
days[8] = "為什麼需要依賴注入?(上)"
下一篇
days[10] = "Plugin是怎麼運作的?"
系列文
Why Flutter why? 從表層到底層,從如何到為何。30

尚未有邦友留言

立即登入留言