无图版,会更有图版

背景

上篇文章(最近总是莫名联动)才写了 electron,主观感受,electron 有这么些好处:

  • 使得前端技术可以运用在本地桌面应用
  • 跨平台
  • Chromium 让兼容性不是首要考虑对象
  • 通过 NodeJS 既可以操作系统,也能利用本身能力与生态

但是:

  • 我好像不需要跨平台
  • 原生开发更加能操作系统 API
  • 扯破大天不就是webview
  • 依然不需要考虑兼容性,并且体积会大幅减小

所以,electron 再见!

思路

这里的需求是,我想要在状态栏挂一个 webapp,它可以操作状态栏图标状态,可以控制通知

这里是大概思路:

  1. 创建一个 MacOS 应用(用 swift 语言
  2. 应用支持状态栏图标,隐藏 dock 图标
  3. 创建 webview (使用 storyboard
  4. 加载本地 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 分钟的问题…

相关阅读