【Xcode 14】iPad管理後台,Let's Go!

這一篇是前兩篇的大一統,第一次寫這種長篇小說,累啊,照慣例,還是要前情提前一下的,要把好不容易做好的後台網頁,放在iPad上面,至於為什麼不放在iPhone上面呢?一來是因為畫面太小,不易使用,二來是RWD跟筆者不熟,加上又多了一個叫iPadOS的關係,想來試試看…題外話,它的觸控筆功能真的很好用耶,非常的順暢,加上類紙膜筆觸真實許多…好久沒寫Swift,都快忘記本業了,趕快來練練手…🤣

做一個長得像這樣的東西

作業環境

項目 版本
CPU Apple M1
macOS Ventura 13.0 arm64
Xcode 14.1 arm64
iPadOS 支援iPadOS 14以上
Golang 1.19.1 arm64
Visual Studio Code 1.72.2 arm64
Postman 10.0 arm64
Node.js 16.15 arm64
Yarn 1.22.18 arm64
Vue CLI 5.0.4 arm64
DB Browser for SQLite 3.12.1 x86_64

Hybird APP

iPad + WKWebView

啟動網頁環境

  • 主要因為這個是WebAPP,所以基本的Web就一定要先跑起來,做法如影片所示…
  • 記得要修改Vue端的IP,不然是打不到API的的,因為你的IP不是我的IP…🤣
  • 可以使用ipconfig查看本機的IP
ipconfig
go run ./
cd html
npm install
yarn server
var (
	// 預先設定的帳號
	SuperUsers = map[string]map[string]model.UserLevel{
		"root":    {"3939889": model.Root},
		"admin":   {"28825252": model.Admin},
		"william": {"987987": model.Account},
	}
)

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>NSAppTransportSecurity</key>
	<dict>
		<key>NSExceptionDomains</key>
		<dict>
			<key>192.168.1.87:port</key>
			<dict>
				<key>NSExceptionAllowsInsecureHTTPLoads</key>
				<true/>
			</dict>
		</dict>
	</dict>
</dict>
</plist>
  • 改好了,就來Build看看吧,有連到了網頁才能做下去…

iOS的部分

取得Token

  • 取得推播Token的部分,這裡也就不再贅述了…
import UIKit
import WWPrint

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    var pushToken: String?
        
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        UNUserNotificationCenter.current()._userNotificationSetting(delegate: self) {
            wwPrint("granted")
        } rejectedHandler: {
           wwPrint("rejected")
        } result: { status in
            wwPrint(status)
        }
        
        return true
    }
    
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        pushToken = deviceToken._hexString()
        pushTokenTest(pushToken ?? "")
    }
}

// MARK: - UNUserNotificationCenterDelegate
extension AppDelegate: UNUserNotificationCenterDelegate {

    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        
        let options : UNNotificationPresentationOptions = [.badge, .sound, .list, .banner]
        completionHandler([options])
    }
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        completionHandler()
    }
}

防偷窺功能

import UIKit
import WWPrint

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
    
    private lazy var guardImageView: UIImageView = guardImageViewMaker(image: #imageLiteral(resourceName: "Logo"))
    
    func applicationWillResignActive(_ application: UIApplication) { addGuardImageView() }
    func applicationDidBecomeActive(_ application: UIApplication) { removeGuardImageView() }
}

// MARK: - 小工具
private extension AppDelegate {
        
    /// 加上防偷窺的ImageView
    func addGuardImageView() { window?.addSubview(guardImageView) }
    
    /// 移除防偷窺的ImageView
    func removeGuardImageView() { guardImageView.removeFromSuperview() }
    
    /// 保護APP的ImageView
    func guardImageViewMaker(image: UIImage) -> UIImageView {
        
        let imageView = UIImageView(image: image)
        imageView.contentMode = .scaleAspectFill
        imageView.frame = window?.frame ?? .zero
        
        return imageView
    }
}

測試網頁

  • 測試網頁還活著才進WKWebView,不然一進去就是錯誤頁面,不好看…
  • 其實就是去取得該網頁的Header的資料,取得到網頁就是活著的…
import UIKit
import WWPrint
import WWNetworking

final class LogoViewController: UIViewController {
    
    static let urlString = "http://192.168.1.102:8080/"
    
    override func viewDidLoad() { super.viewDidLoad() }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        checkNextPage()
    }
    
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        
        guard let viewController = segue.destination as? HtmlViewController else { return }
        viewController.urlString = Self.urlString
    }
    
    deinit { wwPrint("\(Self.self) deinit.") }
}

// MARK: - 小工具
extension LogoViewController {
    
	/// 利用Segue切換UIViewController
    func gotoNextPageWithSegue(_ segue: String) {
        self.performSegue(withIdentifier: segue, sender: nil)
    }
    
	/// 測試網頁還活著才進入WebView
    func checkNextPage() {
        
        WWNetworking.shared.header(urlString: Self.urlString) { result in

            switch result {
            case .failure(let error): wwPrint(error)
            case .success(let info):

                guard let statusCode = info.response?.statusCode,
                      statusCode == 200
                else {
                    return
                }

                DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.gotoNextPageWithSegue(HtmlViewController.segue) }
            }
        }
    }
}

與網頁互動

彈出視窗

// MARK: - WKUIDelegate
extension HtmlViewController: WKUIDelegate {
    
    func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
        webView._showAlertController(target: self, title: nil, message: message, completionHandler: completionHandler)
    }
    
    func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
        webView._showComfirmAlertController(target: self, title: nil, message: message, completionHandler: completionHandler)
    }
    
    func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
        webView._showPromptAlertController(target: self, title: nil, prompt: prompt, defaultText: defaultText, completionHandler: completionHandler)
    }
}
alert("天氣好冷啊…");
alert("什麼都漲就是薪水不漲!");
alert("求上天給我很多錢錢吧");

confirm("老闆請加薪");

prompt("請輸入你的薪水","22K");

WebKit

// MARK: - 小工具
private extension HtmlViewController {
    
    /// WebView切始化設定
    func initSetting() {
        _ = myWebView._addScriptMessageKeys(delegate: self, keys: [.alert, .restart, .update])
    }
// MARK: - WKScriptMessageHandler
extension HtmlViewController: WKScriptMessageHandler {
    
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        
        guard let messageKey = Constant.ScriptMessageKey(rawValue: message.name) else { return }
        
        switch messageKey {
        case .alert: myWebView._showAlertController(target: self, title: message.name, message: "\(message.body)") {}
        case .restart: UIApplication.shared._restartApp()
        case .update: updateVersionAction(userContentController, didReceive: message)
        }
    }
}
// MARK: - WKWebView (class function)
extension WKWebView {

    /// 加上網頁端要傳訊息給APP的名稱
    func _addScriptMessageKeys(delegate: WKScriptMessageHandler, keys: [Constant.ScriptMessageKey]?) -> Bool {
        
        guard let keys = keys, !keys.isEmpty, let keySet = Optional.some(Set(keys)) else { return false }
        
        keySet.forEach { (key) in self.configuration.userContentController.add(delegate, name: key.rawValue) }
        return true
    }
}
  • 但它呢,在WKWebView有個特殊功能 - WKScriptMessageHandler,可以註冊關鍵字讓javascript使用…
  • 不過很少這樣用,因為要先在程式內註冊關鍵字,以後要加一個功能,就要再上架一次才行…
  • 這裡還是使用萬能的Safari Technology Preview來做測試吧…
// window.webkit.messageHandlers.<key>.postMessage("<訊息>")

window.webkit.messageHandlers.Alert.postMessage("可不可以,加一點點錢錢就好…")
window.webkit.messageHandlers.Update.postMessage('{"store":"store","id":"id443904275"}')
window.webkit.messageHandlers.Restart.postMessage("")

開新分頁

<a href="https://www.google.com" target="_blank">Google</a>
// MARK: - WKNavigationDelegate
extension HtmlViewController: WKNavigationDelegate {
    
	/// 網頁剛剛寫上網址的時候
    func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation) {}
    
	/// 網頁產生Session的時候
    func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {}
    
	/// 網頁讀完的時候
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation) {}

	/// 網址錯誤的時候
    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {}

	/// 網頁讀取錯誤的時候
    func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {}
    
	/// 送出網址的權限設定 (app ->)
    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        let actionPolicy = navigationActionPolicyRule(webView, decidePolicyFor: navigationAction)
        decisionHandler(actionPolicy)
    }
    
	/// 接收網址的權限設定 (app <-)
    func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping ((WKNavigationResponsePolicy) -> Void)) {
		decisionHandler(.allow)
    }
}
private extension HtmlViewController {
	
	/// 網頁網址的放行規則
    func navigationActionPolicyRule(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) -> WKNavigationActionPolicy {
        
        guard let url = navigationAction.request.url,
              let components = URLComponents(string: url.absoluteString),
              let scheme = components.scheme,
              let host = components.host
        else {
            return .cancel
        }
        
        switch (scheme, host) {
        case ("https", "apps.apple.com"): Task { let result = await url._openAppStoreWithInside(delegate: self); wwPrint(result) }; return .cancel
        case ("https", "play.google.com"): safariViewController = url._openUrlWithInside(delegate: self); return .cancel
        default: break
        }
                
        if (navigationAction._isBlankLink()) { UIApplication.shared.open(url); return .cancel }
        return .allow
    }
}

下載檔案 / 註冊Token

  • 這個功能在網頁真的是超簡單的,什麼都不用做,點網址就下載了,但在APP端就麻煩了啊…
  • 選擇一:把網址給APP端,交給APP自己下載,但這樣就會變成,網頁 / iOS / Andriod三端,自己下載自己的,如果有問題會很難Debug…
  • 選擇二:先在網頁端下載完成後,再把檔案轉成Base64的Data,再存起來就好了…
  • 這裡使用選擇二,細節就不多說了,看圖先…

const Utility = {

	// 下載二進制檔案 for APP
    forceDownloadFileWithApp: (blob: Blob, fullName: string) => {

    	util.blobToBase64(blob).then((data) => {

          let _result = {
            data: data,
            name: fullName,
            type: blob.type
          }

          window.result = _result
          util.mobileProtocol(Constant.AppProtocol.download)
        })
    },
}

  • 註冊Token的話,也是差不多的…就是去執行js端的function,把相關的值傳過去就可以了…
private extension HtmlViewController {
	
	/// 網頁網址的放行規則
    func navigationActionPolicyRule(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) -> WKNavigationActionPolicy {
        
        guard let url = navigationAction.request.url,
              let components = URLComponents(string: url.absoluteString),
              let scheme = components.scheme,
              let host = components.host
        else {
            return .cancel
        }
        
        switch (scheme, host) {
        case ("app", "home"): Task { let result = await pushTokenRegistry(with: webView); wwPrint(result) }; return .cancel
        case ("app", "downloadFile"): Task { let result = await storeWebFile(with: webView); wwPrint(result) }; return .cancel
        case ("https", "apps.apple.com"): Task { let result = await url._openAppStoreWithInside(delegate: self); wwPrint(result) }; return .cancel
        case ("https", "play.google.com"): safariViewController = url._openUrlWithInside(delegate: self); return .cancel
        default: break
        }
                
        if (navigationAction._isBlankLink()) { UIApplication.shared.open(url); return .cancel }
        return .allow
    }

	/// 向網頁註冊推播Token
    func pushTokenRegistry(with webView: WKWebView) async -> Result<Any?, Error> {
        
        if (isRegistratedPushToken) { return .success(nil) }
        
        guard let appId = Bundle.main._appBundleId(),
              let appDelegate = UIApplication.shared.delegate as? AppDelegate,
              let token = appDelegate.pushToken
        else {
            return .success(nil)
        }
        
        let script = """
        window.pushTokenRegistry({"appId":"\(appId)", "token":"\(token)"})
        """
        let result = await webView._evaluateJavaScript(script: script)
        isRegistratedPushToken = true

        return result
    }
}

範例程式碼下載 - GitHub

後記

  1. 其實寫這篇的主要原因,一來是想要補一下網頁跟後端的一些基礎,二來呢,之前使用Flutter做了WebView的功能,發現APP的容量居然是300MB以上,iOS的這支也才10MB,而且也不用為了兩平台共同相容的套件擔心,最重要的是,之前還有聽說因為Flutter的某個WebView套件,不合Apple的規範,居然不能上架,真的太可怕了啊…疫情之下,各行各業真的都不太好,最後祝大家,職涯都一路生花
  2. 寫完之後,發現這次的影片超多的,容量很大啊;其實啊,到我這個年紀了,應該是要朝管理職前進,反而是學的廣比深還來得重要,因為你怎麼樣都不可能比剛畢業的學生學的還要新,加上又有大腦記憶體已經被舊的東西佔了一部分,他們可以不用知道組合語言 / Objective-C,甚至連UIKit都可以不用學得太深,直接使用SwiftUI…但是,你的經驗一定遠遠超過他們,踩過的坑比他們吃過的鹽還多啊…真心話一句,學要學最新的,用要用最穩的,Willam沉痛筆
  3. 這一篇其實寫的滿重點的,因為重點在後端,後端真的辛苦了(不是同一人嗎?),其實裡面的細節很多很多,但理念都有帶到;其實難是難在如何去規劃,讓三端都方便,寫得好累啊,晚安…