【Swift 5】我到底身在何方?我到底去到何處?

因為呢,個人不務正業太久了,又加上新冠肺炎疫情的緣故,這來寫寫這個有關定位 / 語系的文章。無論你身在何方 無論你去到何處,我都要定位的到你啦,怎麼有點恐怖情人的感覺…XD,主要是在工作上有用到,用來判斷該給什麼語系的資料,所以想寫個定位懶人包以後可以用,這次的文章比較多Code,話不多說,就讓我們繼續看下去

作業環境

項目 版本
CPU Apple M1
macOS Big Sur 11.4
Xcode 12.5

完成的結果

設得資訊

事前準備

  • 這裡先準備一些常數,主要是要取得手機的地區 / 語系 / SIM / GPS定位的資訊,應該沒有其它可以說明所在地的資訊了吧?
// MARK: - 自定義的Print
public func wwPrint<T>(_ msg: T, file: String = #file, method: String = #function, line: Int = #line) {
    #if DEBUG
        Swift.print("🚩 \((file as NSString).lastPathComponent)\(line) - \(method) \n\t\(msg)")
    #endif
}

// MARK: - Utility (單例)
final class Utility: NSObject {
    static let shared = Utility()
    private override init() {}
}

// MARK: - typealias
extension Utility {

    // 定位位置 => [GPS / SIM卡 / 首選語言 / 區域]
    typealias LocationCountryCode = (GPS: String?, SIMs: [String], preferredLanguage: String?, region: String?)
    
    // 語言資訊 => [語系-分支-地區]
    typealias LanguageInfomation = (code: String.SubSequence?, script: String.SubSequence?, region: String.SubSequence?)
    
    // 定位的相關資訊
    typealias LocationInfomation = (location: CLLocation?, isAvailable: Bool)
}

// MARK: - Collection (override class function)
extension Collection {

    /// [為Array加上安全取值特性 => nil](https://stackoverflow.com/questions/25329186/safe-bounds-checked-array-lookup-in-swift-through-optional-bindings)
    subscript(safe index: Index) -> Element? { return indices.contains(index) ? self[index] : nil }
}

取得裝置區域

  • 取得裝置區域,也就是在『設定 -> 一般 -> 語言與地區 -> 地區』的資訊,目前是取得『TW』這個文字。地區代碼的標準是ISO 3166-1
// MARK: - 我到底身在何方?我到底去到何處?
final class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()        
        wwPrint(Locale._currentRegionCode())    // Optional("TW")
    }
}

// MARK: - Locale (static function)
extension Locale {
    
    /// 取得裝置區域
    /// - 設定 -> 一般 -> 語言與地區 -> 地區 (TW)
    /// - Returns: 區域辨識碼
    static func _currentRegionCode() -> String? { return Locale.current.regionCode }
}

取得裝置語系

  • 語系,也就是在『設定 -> 一般 -> 語言與地區 -> 偏好語言的順序』的資訊,其實也就是iPhone使用的語言,目前是取得『ja-TW』這個文字。要注意的是,語系的地區不一定跟區域是一樣的。
// MARK: - 我到底身在何方?我到底去到何處?
final class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        wwPrint(Locale._preferredLanguageInfomation())  // Optional((code: Optional("ja"), script: nil, region: Optional("TW")))
    }
}

// MARK: - Locale (static function)
extension Locale {
    
    /// 取得裝置區域
    /// - 設定 -> 一般 -> 語言與地區 -> 地區 (TW)
    /// - Returns: 區域辨識碼
    static func _currentRegionCode() -> String? { return Locale.current.regionCode }
    
    /// 取得首選語系 (第一個語系)
    /// - 設定 -> 一般 -> 語言與地區 -> 偏好的語言順序 (zh-Hant-TW / [語系-分支-地區])
    /// - Returns: [語系辨識碼](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPInternational/LanguageandLocaleIDs/LanguageandLocaleIDs.html)
    static func _preferredLanguage() -> String? {
        guard let language = Locale.preferredLanguages.first else { return nil }
        return language
    }
    
    /// 把完整的語系編碼分類
    /// - zh-Hant-TW => [語系-分支-地區]
    /// - Parameter language: 完整的語系文字
    /// - Returns: Utility.LanguageInfomation?
    static func _preferredLanguageInfomation(_ language: String? = Locale.preferredLanguages.first) -> Utility.LanguageInfomation? {
        
        guard let preferredLanguage = language,
              let languageInfos = Optional.some(preferredLanguage.split(separator: "-")),
              languageInfos.count > 0
        else {
            return nil
        }
        
        var info: Utility.LanguageInfomation = (nil, nil, nil)
        
        switch languageInfos.count {
        case 1: info.code = languageInfos.first
        case 2: info.code = languageInfos.first; info.region = languageInfos.last
        case 3: info.code = languageInfos.first; info.region = languageInfos.last; info.script = languageInfos[safe: 1]
        default: break
        }
        
        return info
    }
}

取得SIM卡供應商的國籍碼

// MARK: - 我到底身在何方?我到底去到何處?
final class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        wwPrint(UIDevice._isoCountryCodes())    // ["TW", "TW"]
    }
}

// MARK: - UIDevice (static function)
extension UIDevice {
    
    /// [SIM卡供應商的國籍碼](https://stackoverflow.com/questions/18798398/how-to-get-the-users-country-calling-code-in-ios)
    /// - iOS 12之後有雙SIM卡 => [ISO 3166-1](https://zh.wikipedia.org/wiki/ISO_3166-1)
    /// - Returns: [String]
    static func _isoCountryCodes() -> [String] {
        
        var codeArray = [String]()
                    
        if let info = CTTelephonyNetworkInfo().serviceSubscriberCellularProviders {
            for (_, value) in info { if let code = value.isoCountryCode { codeArray.append(code.uppercased()) } }
        }
        
        return codeArray
    }
}

整合一下

  • 接下來,把這些功能整合成一個function。
// MARK: - CLLocationManager (static function)
extension CLLocationManager {
    
    /// 取得該裝置的國家地域碼 (不包含GPS定位)
    /// - Returns: LocationCountryCode
    static func _locationCountryCode() -> Utility.LocationCountryCode {
        
        var code = Utility.LocationCountryCode(GPS: nil, SIMs: [], preferredLanguage: nil, region: nil)
        
        code.SIMs = UIDevice._isoCountryCodes()
        code.region = Locale._currentRegionCode()
        
        if let codeWithLanguage = Locale._preferredLanguageInfomation()?.region { code.preferredLanguage = String(codeWithLanguage) }
        
        return code
    }
}

手機定位

定位權限

NSLocationWhenInUseUsageDescription
// MARK: - Utility (單例)
final class Utility: NSObject {
    
    static let shared = Utility()
    
    lazy var locationManager = { CLLocationManager._build(delegate: self) }()
    private override init() {}
}

// MARK: - CLLocationManagerDelegate
extension Utility: CLLocationManagerDelegate {
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {}
    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {}
    func locationManager(_ manager: CLLocationManager, didFinishDeferredUpdatesWithError error: Error?) {}
}

// MARK: - CLLocationManager (static function)
extension CLLocationManager {
    
    /// 定位授權Manager產生器
    /// - Parameters:
    ///   - delegate: CLLocationManagerDelegate
    ///   - desiredAccuracy: 定位的精度
    ///   - distanceFilter: 過濾的距離
    /// - Returns: CLLocationManager
    static func _build(delegate: CLLocationManagerDelegate?, desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest, distanceFilter: CLLocationDistance = 1) -> CLLocationManager {
        
        let locationManager = CLLocationManager()

        locationManager.delegate = delegate
        locationManager.desiredAccuracy = desiredAccuracy
        locationManager.distanceFilter = distanceFilter
        
        return locationManager
    }
}

Notification

  • 因為它並不是一定馬上可以取到的值,所以這裡利用NotificationCenter來傳送數值。
// MARK: - Notification (static function)
extension Notification {
    
    /// String => Notification.Name
    /// - Parameter name: key的名字
    /// - Returns: Notification.Name
    static func _name(_ name: String) -> Notification.Name { return Notification.Name(rawValue: name) }
    
    /// NotificationName => Notification.Name
    /// - Parameter name: key的名字 (enum)
    /// - Returns: Notification.Name
    static func _name(_ name: Utility.NotificationName) -> Notification.Name { return name.value }
}

// MARK: - NotificationCenter (class function)
extension NotificationCenter {
    
    /// 註冊通知
    /// - Parameters:
    ///   - name: 要註冊的Notification名稱
    ///   - queue: 執行的序列
    ///   - object: 接收的資料
    ///   - handler: 監聽到後要執行的動作
    func _register(name: Notification.Name, queue: OperationQueue = .main, object: Any? = nil, handler: @escaping ((Notification) -> Void)) {
        self.addObserver(forName: name, object: object, queue: queue) { (notification) in handler(notification) }
    }
    
    /// 發射通知
    /// - Parameters:
    ///   - name: 要發射的Notification名稱
    ///   - object: 要傳送的資料
    func _post(name: Notification.Name, object: Any? = nil) { self.post(name: name, object: object) }

    /// 移除通知
    /// - Parameters:
    ///   - observer: 要移除的位置
    ///   - name: 要移除的Notification名稱
    ///   - object: 接收的資料
    func _remove(observer: Any, name: Notification.Name, object: Any? = nil) { self.removeObserver(observer, name: name, object: object) }
}

啟用定位服務

  • 在這裡就可以試看看定位功能是不是可以正常使用?
// MARK: - 我到底身在何方?我到底去到何處?
final class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        Utility.shared.locationManager._locationServicesAuthorizationStatus {
            wwPrint("alwaysHandler")
        } whenInUseHandler: {
            wwPrint("whenInUseHandler")
        } deniedHandler: {
            wwPrint("deniedHandler")
        }
    }
}

// MARK: - CLLocationManager (class function)
extension CLLocationManager {
     
    /// 定位授權的各種狀態處理
    /// - requestWhenInUseAuthorization() => 詢問是否要開啟定位授權的提示視窗
    /// - info.plist => NSLocationAlwaysAndWhenInUseUsageDescription
    /// - Parameters:
    ///   - alwaysHandler: 選擇always後的動作
    ///   - whenInUseHandler: 選擇whenInUse後的動作
    ///   - deniedHandler: 選擇denied後的動作
    func _locationServicesAuthorizationStatus(alwaysHandler: @escaping () -> Void, whenInUseHandler: @escaping () -> Void, deniedHandler: @escaping () -> Void) {

        let authorizationStatus = CLLocationManager.authorizationStatus()
        
        switch authorizationStatus {
        case .notDetermined: requestWhenInUseAuthorization()
        case .authorizedWhenInUse: whenInUseHandler()
        case .authorizedAlways: alwaysHandler()
        case .denied: deniedHandler()
        case .restricted: deniedHandler()
        @unknown default: fatalError()
        }
    }
}

定位協定

  • 利用CLLocationManagerDelegate來取得相關的值,進而做處理。
// MARK: - enum
extension Utility {
    
    /// 自訂錯誤
    enum MyError: Error, LocalizedError {
        
        var errorDescription: String { errorMessage() }

        case notGeocodeLocation
        
        /// 顯示錯誤說明
        /// - Returns: String
        private func errorMessage() -> String {
            
            switch self {
            case .notGeocodeLocation: return "地理編碼錯誤"
            }
        }
    }
    
    /// NotificationName
    enum NotificationName {
        
        /// 顯示真實的值
        var value: Notification.Name { return notificationName() }
        
        // 定位服務 (自定義)
        case _locationServices
        
        /// 顯示真實的值 => Notification.Name
        func notificationName() -> Notification.Name {
            
            switch self {
            case ._locationServices: return Notification._name("_locationServices")
            }
        }
    }
}

// MARK: - CLLocationManagerDelegate
extension Utility: CLLocationManagerDelegate {
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { countryCode(with: manager, locations: locations) }
    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { locationManagerStatus(manager, status: status) }
    
    func locationManager(_ manager: CLLocationManager, didFinishDeferredUpdatesWithError error: Error?) {
        let code = CLLocationManager._locationCountryCode()
        NotificationCenter.default._post(name: Notification._name(._locationServices), object: code)
    }
}

定位權限的狀態處理

  • LocationManager的狀態處理,當權限許可時,就可以更新位置,不然就停下來。
// MARK: - 定位相關 (CLLocationManager)
extension Utility {
    
    /// LocationManager狀態處理
    /// - Parameters:
    ///   - manager: CLLocationManager
    ///   - status: CLAuthorizationStatus
    private func locationManagerStatus(_ manager: CLLocationManager, status: CLAuthorizationStatus) {
        
        switch status {
        case .authorizedAlways: manager.startUpdatingLocation()
        case .authorizedWhenInUse: manager.startUpdatingLocation()
        case .notDetermined: manager.stopUpdatingLocation()
        case .denied: manager.stopUpdatingLocation()
        case .restricted: manager.stopUpdatingLocation()
        @unknown default: fatalError()
        }
    }
}

取得有效的位置資訊

  • manager._locationInfomation():取得有效的位置資訊。
  • _placemark():將坐標(25.04216003, 121.52873230) => CLPlacemark (地址)。
  • _locationCountryCode:取得該裝置的國家地域碼 (不包含GPS定位)。
  • 最後再利用NotificationCenter把自定義的NotificationName發送出去。
// MARK: - 定位相關 (CLLocationManager)
extension Utility {
    
    /// 取得該裝置的國家地域碼
    /// - Notification._name(._locationServices)
    /// - Parameters:
    ///   - manager: CLLocationManager
    ///   - locations: [CLLocation]
    private func countryCode(with manager: CLLocationManager, locations: [CLLocation]) {
        
        var code = CLLocationManager._locationCountryCode()
        
        guard manager._locationInfomation(with: locations).isAvailable,
              let location = manager._locationInfomation(with: locations).location
        else {
            NotificationCenter.default._post(name: Notification._name(._locationServices), object: code); return
        }

        location.coordinate._placemark { (result) in
                        
            switch result {
            case .failure(_): NotificationCenter.default._post(name: Notification._name(._locationServices), object: code)
            case .success(let placemark):
                manager.stopUpdatingLocation()
                code.GPS = placemark.isoCountryCode
                NotificationCenter.default._post(name: Notification._name(._locationServices), object: code)
            }
        }
    }
}
// MARK: - CLLocationManager (class function)
extension CLLocationManager {
     
    /// 處理位置的相關資料
    /// - 最後一筆的有效位置 > 0
    /// - Parameter locations: 取到的位置們
    /// - Returns: Utility.LocationInfomation
    func _locationInfomation(with locations: [CLLocation]) -> Utility.LocationInfomation {

        guard let location = locations.last,
              let isAvailable = Optional.some(location.horizontalAccuracy > 0)
        else {
            return (nil, false)
        }

        return (location, isAvailable)
    }
}
// MARK: - CLLocationCoordinate2D (class function)
extension CLLocationCoordinate2D {
    
    /// 將坐標(25.04216003, 121.52873230) => CLPlacemark (地址)
    /// - Parameters:
    ///   - preferredLocale: 傳回的資料語系區域
    ///   - result: Result<CLPlacemark, Error>
    func _placemark(preferredLocale: Locale = Locale(identifier: "zh_TW"), result: @escaping (Result<CLPlacemark, Error>) -> Void)  {

        let location = CLLocation(latitude: latitude, longitude: longitude)
        let geocoder = CLGeocoder()

        geocoder.reverseGeocodeLocation(location, preferredLocale: preferredLocale) { placemarks, error in
            
            if let error = error { result(.failure(error)); return }
            if let placemark = placemarks?.first { result(.success(placemark)); return }
            
            result(.failure(Utility.MyError.notGeocodeLocation))
        }
    }
}

使用方法

  • 雖然寫起來有點複雜,但使用起來卻是超簡單的。
  • 先註冊Notification,然後就可以用了。
// MARK: - 我到底身在何方?我到底去到何處?
final class ViewController: UIViewController {

    @IBOutlet weak var resultLabel: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let notificationName = Notification._name(._locationServices)
        
        NotificationCenter.default._register(name: notificationName) { notification in
            self.resultLabel.text = notification.object.debugDescription
        }
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        Utility.shared.locationManager._locationServicesAuthorizationStatus {
            wwPrint("alwaysHandler")
        } whenInUseHandler: {
            wwPrint("whenInUseHandler")
        } deniedHandler: {
            self.resultLabel.text = "\(CLLocationManager._locationCountryCode())"
        }
    }
}

範例程式碼下載

後記

  • 原本還以為應該滿簡單的,不過使用起來還滿不錯用的,雖然寫起來滿複雜,但至少在使用上,能很靈活的去排定結果的前後順序,維護上應該也滿容易的。
  • 寫註解也是滿累的,不過我可是個有良心的iOS打字工啊…XD