iOS App 程式開發

利用 Network Framework 輕易監控網路狀態變化

所有與伺服器交換資料的 App,都需要獲取所需的網路資訊,並觀察其變化。隨著 Apple 在 iOS 12 提供的 Network Framework ,要取得這些資料並加以處理就變得十分簡單。此文將會教你利用它來監控網路變動,並建立一個小型客製化框架,來把它製作成一個可重複使用的元件。
利用 Network Framework 輕易監控網路狀態變化
利用 Network Framework 輕易監控網路狀態變化
In: iOS App 程式開發, Swift 程式語言, Xcode

大家好,歡迎閱讀本次教學。毫無疑問,所有與伺服器交換資料的 App,都需要知道一件事情:它們是否已連接到網路。當處於離線時,我們通常需要更改使用者體驗,並更新使用者介面,以反映出 App 無法執行網路操作。此外,即使 App 已經連上了網路,我們還是需要了解連線的類型(像是無線網路或行動網路)。沒有人會想在不知情的情況下,使用一個以行動網路讀取大量資料的 App,畢竟這可能導致使用者在流動網絡上有額外支出。使用者應該能夠依據自己的意願,開啟或關閉這個功能。

值得慶幸的是,隨著 Apple 在 iOS 12 提供了 Network 框架 (Network Framework),要取得所需資料來決定上述內容變得十分簡單。有了 Network 框架,取得網路狀態、及收到狀態的變化就成為了一個標準且簡單的過程,我們將會在這篇文章中詳細介紹。在 iOS 12 推出之前,獲取所需的網路資訊、並觀察其變化是一件繁瑣的事,因為它是基於 SCNetworkReachability API,一個像 C 語言的解決方案。這些年來,許多客製化實作似乎得以讓使用 SCNetworkReachability 變得容易,不過現在,Network 框架已經出現接近一年,它將很快成為歷史。

在本篇教學中,我們不僅會看到 Network 框架的細節,以及如何利用它來監控網路變動,還會建立一個小型客製化框架,來把它製作成一個可重複使用的元件。此外,我們更會逐步教大家把客製化框架作為 CocoaPod 發送出去。是不是很有趣呢?

範例專案預覽

這次的專案比較簡單,我們會將網路相關資訊顯示在 TableView 上。更具體地說,App 將會顯示裝置是否已連上任何網路介面(無線網路、行動網路等)、網路介面類型是甚麼、所有可用的網路介面、以及判斷目前的網路介面是否屬於昂貴的操作。我們將在下文詳述所有內容。

iOS 網路框架範例

上述所有內容都需要一些事前工作,就是實作一個名為 NetStatus客製化類別。在這個類別中,我們會利用 iOS SDK 的 Network 框架,並建立一個客製化並易於使用的 API,它可以被整合進以後開發的任何 App 中。在 Network 框架中,我們可以做的並不多,所以實作很快就完成。這是個好消息,因為這樣我們就有機會建立一個基於 NetStatus 類別的小型容製化框架,以便我們可以輕易地在各種 iOS 專案中使用它。

請先下載初始專案,當中一些初步作業已經完成。在文章的某個部分中,我們會指示你保留一份專案的副本,因為我們將進行一些重要的更改。也就是說,你會有兩個版本的最終專案:第一個將包含範例 App 及 NetStatus 類別的專案,而第二個將會包含範例 App 及客製化框架,而該客製化框架是使用本地 Pod 整合進範例 App 中。

所以,好好享受這篇文章,並準備迎接 Network 框架吧!然後,我們會學習如何製作自己的開源 Swift 框架,並以 CocoaPod 的方式發送。

建立一個單例類別 (Singleton Class)

我們將直接從實作客製化類別開始。透過這個類別的方法和屬性,我們將建置並提供一個可重複使用的 API,讓 Network 框架的功能易於使用。

我們的工作將在 NetStatus.swift 檔案裡進行,你可以在初始專案中找到這個檔案,它現在應該是空無一物的。我們將類別保持相同的名稱:

class NetStatus {

}

在進行下一步之前,先匯入 Network 框架,好讓我們能夠使用它的 API:

import Network

NetStatus 是個單例類別,所以我們不需要在使用它的時候建立其實例。單例模式有兩個必要條件:第一,我們必須在類別裡初始化一個類別的 static shared 實例:

static let shared = NetStatus()

第二,我們必須要有一個私有的初始器 (initializer):

private init() {

}

棒極了!現在我們有了以下的程式碼:

class NetStatus {
    static let shared = NetStatus()

    private init() {

    }
}

任何我們在這個類別中定義的屬性及方法,都能被專案任何地方的 shared 實例使用(例如 NetStatus.shared.XXX)。

Network 框架實際上是提供一個簡單的方式,透過 NWPathMonitor 類別來觀察網路變化。讓我們來宣告一個 NWPathMonitor 型別的屬性:

var monitor: NWPathMonitor?

這可能是最重要的一個屬性,因為幾乎所有關於 Network 框架的事情都是透過它來完成的。

此外,讓我們來宣告一個旗標,來標示類別是否正在觀察網路狀態的變化:

var isMonitoring = false

很明顯地,預設值被設定為 false,因為沒有任何監控發生。

讓我們宣告一些回呼處理器(閉包),在以下情況會呼叫它們:

  • 類別開始監控網路變化。
  • 類別停止監控網路變化。
  • 網路狀態出現變更。

以下是所需的程式碼:

var didStartMonitoringHandler: (() -> Void)?

var didStopMonitoringHandler: (() -> Void)?

var netStatusChangeHandler: (() -> Void)?

任何將會使用 NetStatus 並且實作上述程式碼的其他實體,都將會在網路變化、監控開始及停止時收到通知。

附註:除了使用閉包外,我們可以選擇使用委託模式 (Delegation pattern) 或透過 NotificationCenter 的通知,來與使用 NetStatus 類別的實體溝通。然而,閉包是最快、最簡單、最直接的方式,來從這個類別傳送訊息到其他實體。

開始監控網路狀態變化

為了從 Network 框架接收網路狀態的變化,我們必須啟動監控。為了達到這個目的,讓我們在類別中定義第一個方法:

func startMonitoring() {

}

首先,我們要檢測 isMonitoring 屬性的數值,來確認是否已經開始在監控。我們將會在尚未啟動監控的情況下開始:

guard !isMonitoring else { return }

我們現在可以初始化 monitor 屬性:

monitor = NWPathMonitor()

然而,這還不夠來觸發實際的監控程序。我們必須呼叫 start(queue:) 方法,並傳送一個 DispatchQueue 作為參數,監控會在此執行。這裡你要記住很重要的一點,就是觀察網路狀態變化需要在背景執行緒中執行,也不可以在主執行緒中執行。所以,讓我們來建立一個 DispatchQueue,然後呼叫方法:

let queue = DispatchQueue(label: "NetStatus_Monitor")
monitor?.start(queue: queue)

下一步,我們要讓 NetStatus 在任何網路狀態變化時通知它的呼叫器,像是從無線網路切換至行動網路,或是完全斷線的時候。這個步驟是透過 pathUpdateHandler 屬性來完成的,它是一個閉包,會回傳含有關於更新網路介面、裝置有否連上網路等資訊的 NWPath 物件。不過,我們在這裡不會直接使用它,反而我們會呼叫前文宣告的 netStatusChangeHandler

monitor?.pathUpdateHandler = { _ in
    self.netStatusChangeHandler?()
}

最後,按需要更新 isMonitoring 旗標,並呼叫 didStartMonitoringHandler 閉包:

isMonitoring = true
didStartMonitoringHandler?()

startMonitoring() 現在已經準備好了:

func startMonitoring() {
    guard !isMonitoring else { return }

    monitor = NWPathMonitor()
    let queue = DispatchQueue(label: "NetStatus_Monitor")
    monitor?.start(queue: queue)

    monitor?.pathUpdateHandler = { _ in
        self.netStatusChangeHandler?()
    }

    isMonitoring = true
    didStartMonitoringHandler?()
}

停止監控

並不是 App 的所有部分都須要監控網路狀態變化,所以能夠停止監控是很重要的。當不需網路監控時,停止監控也可以節省資源。

讓我們來定義一個新方法:

func stopMonitoring() {

}

首先,我們會確認類別是否正在監控,以及 monitor 屬性是否已經被初始化:

guard isMonitoring, let monitor = monitor else { return }

要停止監控,我們只需要呼叫下列的 cancel() 方法:

monitor.cancel()

之後,也要更新我們客製化的屬性:

self.monitor = nil
isMonitoring = false

我們亦要呼叫 didStopMonitoringHandler,來通知使用這個類別的任何實體:

didStopMonitoringHandler?()

以下是 stopMonitoring() 方法:

func stopMonitoring() {
    guard isMonitoring, let monitor = monitor else { return }
    monitor.cancel()
    self.monitor = nil
    isMonitoring = false
    didStopMonitoringHandler?()
}

每當我們想要停止監控網路狀態變化時,就會以 NetStatus.shared.stopMonitoring() 方式來呼叫上述的程式碼。不過,如果方法沒有明確地被調用的話,會發生甚麼事呢?

init() 之後實作 deinit。如果監控沒有停止的話,在 deinit 裡呼叫 stopMonitoring()

deinit {
    stopMonitoring()
}

提供網路狀態資料

能夠開始及停止網路變化的監控,只是我們工作的一半而已,另外一半是關於一般 App 需要知道的重要資料。我們將透過我們的類別讓下列事項容易使用及存取:

  • App 是否正在連線到一個網路介面(無線網路、及行動網路等)。
  • 現在的網路介面類型。
  • App 在某個時刻可用的網路介面類型。
  • App 是否正在使用一個被認為是昂貴的特定網路介面類型。

我們將用 get-only 屬性來實作上述功能。

判斷裝置是否連線

首先,我們建立一個布林 (Boolean) 屬性,它將標示裝置是否連線到網路介面:

var isConnected: Bool {
    guard let monitor = monitor else { return false }
    return monitor.currentPath.status == .satisfied
}

如果 monitor 屬性因為類別現在沒有監控網路變化而是 nil 的話,我們就只回傳 false。當 monitor 是 nil 的時候,我們並沒有辦法判定裝置是否有連線。

相反的情況下,我們檢測 monitor 現行網路路徑(currentPath 屬性)的 status 屬性數值。如果數值等於 satisfied,就代表裝置已連線到一個網路介面。你可以閱讀相關文章,來了解 status 屬性和其可能數值的資訊。當網路變化發生時,currentPath 屬性就會自動更新,所以每次查詢時它都會回傳真實資料。

取得現在的網路介面類型

除了知道裝置是否連上網路之外,知道它所連接的網路介面類型也是很有用的。讓 NetStatus 類別能夠判定 App 是連上了無線網路、行動網路、或是使用有線網路(乙太網路線,用於桌上電腦 macOS App)等,這就是另一個十分有用且必要的功能。

像前面一樣,以下是另一個 get-only 屬性:

var interfaceType: NWInterface.InterfaceType? {
    guard let monitor = monitor else { return nil }

    return monitor.currentPath.availableInterfaces.filter {
        monitor.currentPath.usesInterfaceType($0.type) }.first?.type
}

同樣地,如果只有 monitor 屬性具有實際值,繼續操作就很重要了。為了獲得當前路徑支持的所有可用介面,currentPath 為我們提供了 availableInterfaces 屬性,它是 NWInterface 對象的集合。通過提供 usesInterfaceType(_:) 方法,我們還可以輕鬆確定當前是否正在使用特定介面。上面的 filter 方法回傳一個新集合,該集合僅包含在請求時正在使用的介面。我們正在訪問其第一個(也是唯一一個)元素,然後返回 type 屬性,也就是實際的介面類型(例如 wifi)。我建議你檢查 type 以外的其他提供的屬性,並像在上文一樣使用它們。

取得可用介面類型

上文我們使用了 availableInterfaces 屬性,來獲取當前網絡路徑的所有可用介面。此集合中的介面類型,對於將要使用 NetStatus 類別的其他實體十分有用,所以我們把它也變為可用的類型吧!

按照相同的模式,讓我們再寫一個屬性:

var availableInterfacesTypes: [NWInterface.InterfaceType]? {
    guard let monitor = monitor else { return nil }
    return monitor.currentPath.availableInterfaces.map { $0.type }
}

Swift 中的高階函式像是 map、或我們上面用到的 filter,已經盡量最小化了範例中所需的程式碼。第二行程式碼是為了建立一個基於 NWInterface 集合的 NWInterface.InterfaceType 物件的新陣列,並提供我們正在尋找的可用網路介面類型。

確認昂貴的網路介面

根據 Apple 官方文件,特定的網路介面(像是行動網路)被認為是昂貴的。Network 框架有一個旗標讓它可以簡單地透過 currentPath 屬性確認這一點,它就叫做 isExpensive。我們將建立一個相似名稱的屬性,並回傳原本的 isExpensive 數值。

var isExpensive: Bool {
    return monitor?.currentPath.isExpensive ?? false
}

請注意,如果 monitor 是 nil 的話,我們就只回傳 false。

實際操作 Network 框架

我們已完成了大部分的主要工作,所以是時候讓 NetStatus 類別運作,並從 Network 框架中取得預期資料。為了這個目的,讓我們切換到 ViewController.swift 檔案,當中範例 App 的一部分和其 UI 已經完成實作了。它包含一個 TableView、以及一個 monitorButton 按鈕,用來開始及停止監控。

TableView 將會包含以下三個 Section:

  • 第一個 Section 只有一個 Row,而它將會標示連線狀態(裝置是否已連線到網路介面)。
  • 第二個 Section 將列出所有可供裝置使用的介面類型。此外,它將視覺化地標示目前正在使用的介面類型。
  • 第三個 Section 將會顯示目前使用的網路介面是否昂貴。

在我們開始之前,我建議你在實機上執行範例 App,不要在虛擬裝置上執行。因為實際狀況的運作是完全不同的,而且如果你使用乙太網路線來連上網路的話,虛擬機可能特別容易誤判。

所以,再次回到我們的操作中。前往 ViewController.swift 檔案裡的 tableView(_:numberOfRowsInSection:) 方法,這裡是必須定義每個 Section 的 Row 的地方,現在第二個 Section 沒有要回傳 Row:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return section != 1 ? 1 : 0
}

這是因為在沒有實作 NetStatus 類別的情況下,我們無法告訴裝置有多少個網路介面可用。現在它已經準備好了,我們可以更新上述程式碼,並回傳可用網路介面的數量,就如下所示:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return section != 1 ? 1 : NetStatus.shared.availableInterfacesTypes?.count ?? 0
}

請注意,如果因為一些原因,availableInterfacesTypes 是 nil 的話,我們就會使用 nil 合併運算子來提供一個預設值,所以在這種情況下不會有 Row。

下一步,讓我們前往 tableView(_:cellForRowAt:) 方法。除了 Dequeueing 及回傳 Cell 之外,這裡沒有其他事情發生。於是,我們在此加入程式碼,以在適當 Section 中顯示適當的資料。我們會從 switch 陳述句開始,並遍歷 indexPath.section

switch indexPath.section {

}

我們已經在第一個 Section 中提過,我們將顯示裝置是否連線到一個網路介面,所以會使用 NetStatus 類別的 isConnected 屬性。我們將透過文字及圖片來呈現:

case 0:
    cell.label?.text = NetStatus.shared.isConnected ? "Connected" : "Not Connected"
    cell.indicationImageView.image = NetStatus.shared.isConnected ? UIImage(named: "checkmark") : UIImage(named: "delete")

在第二個 Section 裡,我們將列出所有可用的網路介面類型;同時,我們也將標記裝置目前正在連接的網路介面。為了達到這個目的,我們將分別使用 availableInterfacesTypes 以及 interfaceType 屬性:

case 1:
    if let interfaceType = NetStatus.shared.availableInterfacesTypes?[indexPath.row] {
        cell.label?.text = "\(interfaceType)"

        if let currentInterfaceType = NetStatus.shared.interfaceType {
            cell.indicationImageView.image = (interfaceType == currentInterfaceType) ? UIImage(named: "checkmark") : nil
        }
    }

最後,我們希望標示現在連接的網路介面是否昂貴,所以最後一個 Section 有這段程式碼:

case 2:
    cell.label?.text = NetStatus.shared.isExpensive ? "Expensive" : "Not Expensive"
    cell.indicationImageView.image = NetStatus.shared.isExpensive ? UIImage(named: "warning") : nil

我們也會需要 default 條件:

default: ()

tableView(_:cellForRowAt:) 方法現在會變成這樣:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "infoCell", for: indexPath) as! InfoCell

    switch indexPath.section {
    case 0:
        cell.label?.text = NetStatus.shared.isConnected ? "Connected" : "Not Connected"
        cell.indicationImageView.image = NetStatus.shared.isConnected ? UIImage(named: "checkmark") : UIImage(named: "delete")

    case 1:
        if let interfaceType = NetStatus.shared.availableInterfacesTypes?[indexPath.row] {
            cell.label?.text = "\(interfaceType)"

            if let currentInterfaceType = NetStatus.shared.interfaceType {
                cell.indicationImageView.image = (interfaceType == currentInterfaceType) ? UIImage(named: "checkmark") : nil
            }
        }

    case 2:
        cell.label?.text = NetStatus.shared.isExpensive ? "Expensive" : "Not Expensive"
        cell.indicationImageView.image = NetStatus.shared.isExpensive ? UIImage(named: "warning") : nil

    default: ()
    }

    return cell
}

我們已經設定好 TableView 了,但還有一些事情要做。下一站是 toggleMonitoring(_:) IBAction 方法,它是我們開始以及停止監控網路狀態變化的地方。這相當簡單:

@IBAction func toggleMonitoring(_ sender: Any) {
    if !NetStatus.shared.isMonitoring {
        NetStatus.shared.startMonitoring()
    } else {
        NetStatus.shared.stopMonitoring()
    }
}

最後,我們將在 viewDidLoad() 方法裡實作閉包,當監控開始或停止、以及網路狀態變化時,讓 ViewController 類別收到通知。

為了滿足我們範例 App 的需要,當監控開始時,我們需要採取的唯一動作,就是更新按鈕的文字:

override func viewDidLoad() {
    ...

    NetStatus.shared.didStartMonitoringHandler = { [unowned self] in
        self.monitorButton.setTitle("Stop Monitoring", for: .normal)
    }
}

而當監控停止時,我們要做的也一樣,不過我們也會重新整理 TableView 來更新視覺指示:

override func viewDidLoad() {
    ...

    NetStatus.shared.didStopMonitoringHandler = { [unowned self] in
        self.monitorButton.setTitle("Start Monitoring", for: .normal)
        self.tableView.reloadData()
    }
}

當網路狀態發生變化時,我們也會重新整理 TableView:

override func viewDidLoad() {
    ...

    NetStatus.shared.netStatusChangeHandler = {
        DispatchQueue.main.async { [unowned self] in
            self.tableView.reloadData()
        }
    }
}

請注意,使用上面的 DispatchQueue,並在主執行緒上重新整理 TableView 是必要的,因為監控狀態變化是在背景執行緒裡進行的,但背景執行緒又不允許 UI 的變動。

我們現在可以執行 App,並看看 App 是否能正確地反映網路狀態的變化。你可以從無線網路切換到行動網路、再切換回來,然後關閉網路,並每次都重開 App 來看看更新過的監控數值:

network framework demo

總結

這次我們走過了許多不同的步驟,除了 Xcode 之外,我們還將目光轉移到其他東西。網路狀態發生變化時,Network 框架讓我們輕易處理及取得資料,而我們實作的 NetStatus 類別讓它更加簡單。

為了讓這個框架更有價值,我們將在下篇文章中建立一個 CocoaPod,讓我們可以透過 CocoaPods dependency manager 輕易地整合並分享框架,同時我們也會建立一個 GitHub Repository 來負責遠端存取。請鎖定接下來的教學文章,到時候我們會看到所有的細節。

我希望你喜歡這次的文章內容,並在這學到些新東西。那麼我們下次再見 👋 !

你可以在 GitHub 下載範例專案作參考。

譯者簡介:楊敦凱-目前於科技公司擔任 iOS Developer,工作之餘開發自有 iOS App 同時關注網路上有趣的新玩意、話題及科技資訊。平時的興趣則是與自身專業無關的歷史、地理、棒球。來信請寄到:[email protected]

原文Network Framework in iOS: How to Monitor Network Status Changes

作者
Gabriel Theodoropoulos
資深軟體開發員,從事相關工作超過二十年,專門在不同的平台和各種程式語言去解決軟體開發問題。自2010年中,Gabriel專注在iOS程式的開發,利用教程與世界上每個角落的人分享知識。可以在Google+或推特關注 Gabriel。
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。