无图版,会更有图版
背景
上篇文章(最近总是莫名联动)才写了 electron,主观感受,electron
有这么些好处:
- 使得前端技术可以运用在本地桌面应用
- 跨平台
Chromium
让兼容性不是首要考虑对象- 通过
NodeJS
既可以操作系统,也能利用本身能力与生态
但是:
- 我好像不需要跨平台
- 原生开发更加能操作系统 API
- 扯破大天不就是
webview
嘛 - 依然不需要考虑兼容性,并且体积会大幅减小
所以,electron 再见!
思路
这里的需求是,我想要在状态栏挂一个 webapp,它可以操作状态栏图标状态,可以控制通知
这里是大概思路:
- 创建一个 MacOS 应用(用 swift 语言
- 应用支持状态栏图标,隐藏 dock 图标
- 创建 webview (使用 storyboard
- 加载本地 webapp (index.html
关于 Native 应用
虽然你会百般嫌弃 xcode,毕竟这是一个开大文件或者脸臭的时候能比 Atom 卡出三个 VSCode 的 IDE,但是钦点的集成平台不得不用
创建应用
通过 Xcode 创建一个 MacOS APP,Swift 语言,UI 是 Storyboard。这年头 SwiftUI 算是完整,所以默认是 SwiftUI,但这里不用
修改Info.plist
- 添加
Application is agent (UIElement)
,值为 YES,目的是不想出现 dock 图标 - 添加
App Transport Security Settings
-Allow Arbitrary Loads
,值为 YES,目的是本地调试 react 应用时,需要支持 http 请求
添加状态栏菜单与弹出窗
在AppDelegate
定义两行属性
let menubar = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let popover = NSPopover()
接着,在applicationDidFinishLaunching
(应用完成启动)定义他们的行为
// if let 爽啊!
if let menuBtn = menubar.button {
menuBtn.title = "click"
menuBtn.action = #selector(togglePopover)
}
// 需要在 Storyboard 添加一个 ViewController,当然可以顺便创建 webview 等会用到
let popoverController = NSStoryboard(name: "Main", bundle: nil)
.instantiateController(withIdentifier: "PopoverViewController") as? PopoverViewController
popover.behavior = .transient
popover.contentViewController = popoverController
#selector
涉及到 OC 的领域了,可以说 Apple 对 MacOS 的上心程度远没有 iOS 高,大量 API 还在基于 NS。(怪不得要整合生态,心有余力不足)
所以这里需要写一个允许 OC 调用的方法,控制弹出窗的显示与隐藏
@objc func togglePopover(_ sender: AnyObject) {
if popover.isShown {
closePopover(sender)
} else {
showPopover(sender)
}
}
本体配置结束
创建 webview
storyboard 拉一个 webview 出来就完事,因为这波对 webview 没有自定义,所以不绑 class
打开刚刚创建的 PopoverViewController
,绑定下组件(从 storyboard 按住 control 拖到类的属性区(不得不说,从第一次接触入门 iOS 我就爱上了苹果这个设计!))
然后就是第二喜欢的「面向协议编程」,扩展 PopoverViewController
,继承 WKNavigationDelegate
// 其实本文其实用不到,先写着
extension PopoverViewController: WKNavigationDelegate {}
接着,viewDidLoad
(视图加载结束)加点东西:
webview.navigationDelegate = self
// 通过 userContentController 获取 webview 中的事件
let contentController = webview.configuration.userContentController
// 定义与前端交互的方法名
contentController.add(self, name: "hello")
接着是获取前端页面并显示,这里需要分成两个情况
- 本地 webpack 调试是启动服务器(localhost:3000)
- 编译后为静态文件,需要读取的是文件(index.html)
两种情况如下,具体自行应用中判断(或者调试完干脆删掉其中一种)
let pagePath = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "build")
// let devUrl = URLRequest(url: URL(string: "localhost:3000")!)
// load 方式也不同,一种是加载文件,一种直接加载 url
webview.loadFileURL(pagePath!, allowingReadAccessTo: pagePath!.deletingLastPathComponent())
// webview.load(devUrl)
WKScriptMessageHandler
这里涉及到一个东西:WKScriptMessageHandler
,根据 Apple Developer
A class conforming to the WKScriptMessageHandler protocol provides a method for receiving messages from JavaScript running in a webpage.
(众所周知,Apple Developer 看和不看没有差别…)
总的来说,这是一个用来与 webview 中的 JavaScript
通讯手段的协议。简单说工作方式即在window
中插入webkit
,其中有一个属性是messageHandlers
,这里下面的属性即在 contentController
约定的属性,前端通过调用这些属性的postMessage
来达到传送信息的目的
所以这里又要用到第二喜欢的“扩展”协议编程,这次扩展的是WKScriptMessageHandler
,需要 require 实现一个方法:userContentController
extension PopoverViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == "hello" {
// 惊讶的发现 js 的 object 在传递到应用之后已经是字典的形状,故直接强转
let body = (message.body as! Dictionary<String, Any>)
// 这里完全可以通过 switch 做模式匹配,不过这里没有
if (body["type"] as! String) == "notify" {
let value: String = (message.body as! Dictionary<String, Any>)["value"] as! String
showNotification(title: value)
}
}
}
// 调用通知 api,显示通知
func showNotification(title: String) {
let notification = NSUserNotification()
notification.deliveryDate = Date(timeIntervalSinceNow: 1)
notification.title = title
NSUserNotificationCenter.default.deliver(notification)
}
}
原生应用青春版配置结束
该 tm 轮到前端了!
又是 80 年切图经验的三板斧:直接create-react-app
开个新应用
准备就绪之后,直接在App.js
写就好了。刚刚定义了方法名hello
,写一个新方法
// 点击按钮弹出通知
const handleClick = () => {
window.webkit.messageHandlers.hello.postMessage({type: 'notify', value: "Hello World"})
}
render
有一个Learn React
?就用你来做button
!
不出意外的话,原生应用与 react 应用调试模式运行起来之后,点击Learn React
就可以看到Hello World
的系统级通知了!
编译成品
首先对 webapp 进行编译,yarn build
。然后将编译后的build
(改个名也行)文件夹拖入 xcode 工程中,注意需要复制,且为create folder references
运行,然后你就会发现一片空白…
原因是原生应用加载文件用的是相对路径,编译后的 react app,看一下index.html
,都是从/
开始的
所以先暂时手工把/
去掉,应用正常运行
总结
本来就只有这些东西,所以真的用不上electron
,虽然麻烦点
打包后的包体积着实惊讶:前端部分 500k,整个应用 600k。不过这个有个问题:与tauri
遇到的兼容性差不多 —— 这里的 webview 就是 safari,而 safari 还有很多 api 的支持做得并不好
同时也算是圆了三年前的想法,当时想用 iOS 验证。原因是小程序的盛行,我在猜是不是通过这种方式 —— 通过约定的方式让前端调用指定的方法,来达到使用硬件与系统 API,又因为是直接通过微信本体通讯,所以不存在类似于跨域这种烦到死的限制
本来这次的验证是因为想快速写个工具,想到这个一直搁置的想法。结果是在印证:花了 3 天时间来解决如何节省 3 分钟的问题…