【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
- 這次呢,主要是要整合Web在iPad的APP之上,也順道了解網頁跟APP上的不同…
- Hybrid APP = 網頁(WebView) + 原生功能,雖然呢,效能上是差了一點,但是對於更新率高的應用,像是商場類的APP卻是一大利多,因為網頁能及時更新內容,免去上架的等待時間,事實上很多的APP的確是這麼做的…
- 雖然看起來好像把網頁放進WebView就解決了,其實不然,光是下載、開分頁,使用瀏覽器幾乎什麼都不用做就可以了,但在APP端是要寫程式的…
啟動網頁環境
- 主要因為這個是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
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
}
}
測試網頁
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) }
}
}
}
}
與網頁互動
彈出視窗
- 重頭戲終於來了,首先,在WKWebView上是不允許使用javascript,讓alert() / comfirm() / prompt(),這種彈跳視窗(Popup)彈出來的…
- 但是,是可以藉由WKUIDelegate,以原生的方法彈出來…
- 當然,也不一樣要用UIAlertController,用套件的也可以,我想Apple的用意應該是不想讓彈出視窗一直亂彈,體驗會很差…
- 大家可以用Safari Technology Preview測試一下下就好…
- 測試後可以發現,其實一次只能彈一個出來…🤣
// 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
- 這個WebKit是一個開源的Web瀏覽器引擎,它被用於Apple Safari,而它的分支Blink被用於基於Chromium的網頁瀏覽器,如Microsoft Edge與Google Chrome…
// 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("")
開新分頁
- 先看看在網頁的時候,這個功能是怎麼作用的…
- 其實在網頁上,只要加上target="_blank"就可以了…
<a href="https://www.google.com" target="_blank">Google</a>
- But,APP那來的分頁?那來的網址列啊?
- 選擇一:就把URL帶出去給Safari開啟,但體驗很差…
- 選擇二:在內部用SFSafariViewController開啟,不過後來實作後發現,AppStore的網址,如果用選擇二開啟,會帶去AppStore APP
- 選擇三:App Store的網址用SKStoreProductViewController開啟 / Google Play的網址用SFSafariViewController,這樣的好處就是可以直接安裝該APP…
- 我們就在webView(_:decidePolicyFor:decisionHandler:)做處理,這段一定要接平板,因為模擬器沒有AppStore…
// 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
後記
- 其實寫這篇的主要原因,一來是想要補一下網頁跟後端的一些基礎,二來呢,之前使用Flutter做了WebView的功能,發現APP的容量居然是300MB以上,iOS的這支也才10MB,而且也不用為了兩平台共同相容的套件擔心,最重要的是,之前還有聽說因為Flutter的某個WebView套件,不合Apple的規範,居然不能上架,真的太可怕了啊…疫情之下,各行各業真的都不太好,最後祝大家,職涯都一路生花…
- 寫完之後,發現這次的影片超多的,容量很大啊;其實啊,到我這個年紀了,應該是要朝管理職前進,反而是學的廣比深還來得重要,因為你怎麼樣都不可能比剛畢業的學生學的還要新,加上又有大腦記憶體已經被舊的東西佔了一部分,他們可以不用知道組合語言 / Objective-C,甚至連UIKit都可以不用學得太深,直接使用SwiftUI…但是,你的經驗一定遠遠超過他們,踩過的坑比他們吃過的鹽還多啊…真心話一句,學要學最新的,用要用最穩的,Willam沉痛筆…
- 這一篇其實寫的滿重點的,因為重點在後端,後端真的辛苦了(不是同一人嗎?),其實裡面的細節很多很多,但理念都有帶到;其實難是難在如何去規劃,讓三端都方便,寫得好累啊,晚安…