仿原生风格 electron 应用的挑战

195 天前
 ChrisFreeMan

这个分享主要是想要让大家知道 electron 对比原生应用,可以做到什么程度,当然水平有限并不是百分百发挥其潜能,水平很有限,能力很一般,我尽力分享,您随意看看。

分享要点

App 展示

所见即所得的 markdown笔记应用 Simark

为什么选择非原生开发

最初开始做独立开发的时候选择的是 swift UI 技术栈,但是使用了 Xcode 开发了两款原生应用还是被其糟糕的开发体验劝退,swift UI 桌面端的不成熟和糟糕的编译速度以及龟速 Xcode 等一系列问题打败了。我还没有吐槽 swiftUI 闭源,文档缺失,不详等一系列问题。在程序员独立开发法则的三个阶段,make it work ,make it right ,make it fast ,光第一个阶段就会把我消耗殆尽。在成品和功能不确定的状态下,应尽快完成原型给自己一个交代,不然耐心失去后就会很容易半途而废。

技术栈上一开始选择了 react.js ,后来我发现会很难维护,框架本身臃肿,太多的隐藏魔法和高度抽象会增加复杂度和不确定性。索性就抛弃任何 UI 框架纯手动操作 Dom ,一开始还很担心会掌控不了,后来发现其优点远远大于担忧,再也没有遇到过无法解释的 UI bug ,掌握每一处 UI 的渲染细节以及流程,能够快速定位遇到的问题,极大的降低了框架本身的高度抽象带来的心智负担。

界面与布局

参考 macOS 的邮件应用的双导航栏加主视图布局风格, 加对称式布局, 这类布局很适合笔记类应用。

UI 字体使用 system-UI ,字体大小为 14px 。字体颜色尽量控制在 3 种颜色以内,主要颜色,次要颜色,和"禁用"颜色。以及加上主题色来点缀,主题色我直接应用系统的设置。

html, body {
  font-family: system-ui;
  font-size: 14px;
  accent-color: ${accentStateHandler.accentState.value};
//获取系统主题色
const accentColor = systemPreferences.getAccentColor()

导航菜单侧边栏透明风格应与原生应用保持一致。

  const mainWindow = new BrowserWindow({
    vibrancy: 'sidebar',

自适应黑暗模式

class ThemeWatcher {
  #isDarkTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
  #publishs: ((isDark: boolean) => void)[] = []

  constructor() {
    this.#onThemeChange()
  }

  #onThemeChange() {
    window.matchMedia('(prefers-color-scheme: dark)')
      .addEventListener('change', e => {
        const { matches: isDarkTheme } = e
        this.#isDarkTheme = isDarkTheme
        for (const pub of this.#publishs) {
          pub(isDarkTheme)
        }
      })
  }

  isDarkTheme() {
    return this.#isDarkTheme
  }

  subscript(subCall: (isDark: boolean) => void) {
    this.#publishs.push(subCall)
  }

  unsubscript(subCall: (isDark: boolean) => void) {
    this.#publishs = this.#publishs.filter(pub => !Object.is(subCall, pub))
  }
}

以及其他一些细节打磨,比如适当的动画过度效果。(因为没有找到合适的 GIF 制作工具,就不加动图了,非常抱歉)。

本地优先的设计原则

一个桌面 App 和网页的区别就在于,加载速度和响应速度,在完成界面展示的同时尽可能的使用数据库存储和同步,保障本地优先。本地数据库的选择很多,sqlite ,levelDB ,pouchDB 等,按照自己喜好就好。数据读写全部由主进程,数据展示交由渲染进程。进程间通讯使用 ipcMain/ipcRender 通讯,这个时候 TypeScript 非常方便的地方就来了,将暴露到渲染端的 ipc 通讯的方法注册到 window 对象,方便类型检查和渲染进程调用。

// preload.ts
declare global {
  interface Window {
    appConfig: typeof appConfigBridge
    stool: typeof toolBridge
    contextMenu: typeof contextMenu
    mainEvent: typeof mainEvent
    notebookHandler: typeof notebookHandler

应用状态保持

比如在窗口每次的移动和大小改变,都需要记录起来,下次打开的时候需要还原其最后一次的位置和大小。

  mainWindow.on('close', async e => {
      const windowBounds = mainWindow.getBounds()
      await windowConfigHandle.setWindowConfig({
        windowX: windowBounds.x,
        windowY: windowBounds.y,
        windowWidth: windowBounds.width,
        windowHeight: windowBounds.height
      })

全局快捷键

应用的全局快捷键都应该被用户轻易找到,并说明功能。将其注册到系统顶部的 app 工具栏中。比如这个编辑功能菜单栏,还可以根据当前焦点是否处于可编辑状态来启用或禁用来更贴近原生风格。

export const macEditMenu = (): MenuItemConstructorOptions => ({
  label: localMenuData.edit,
  id: 'EditMenu',
  submenu: [
    {
      id: menuIDs.undo,
      label: localMenuData.undo,
      click: () => {
        if (menuStateManage.inEditorState.value) return
        getFocusedWindow()?.webContents.undo()
      },
      enabled: menuStateManage.editState.value,
      accelerator: 'CommandOrControl+Z',
    },
    {
      id: menuIDs.redo,
      label: localMenuData.redo,
      click: () => {
        if (menuStateManage.inEditorState.value) return
        getFocusedWindow()?.webContents.redo()
      },
      enabled: menuStateManage.editState.value,
      accelerator: 'CommandOrControl+Shift+Z'
    },
    { type: 'separator' },
    {
      id: menuIDs.cut,
      label: localMenuData.cut,
      click: () => {
        getFocusedWindow()?.webContents.cut()
      },
      enabled: menuStateManage.editState.value,
      accelerator: 'CommandOrControl+X'
    },

独立的设置窗口

拥有独立的设置窗口并且保持和其他原生 app 风格一致,拥有独立的风格统一的设置窗口更加贴近原生体验,可以监听窗口的焦点事件,然后根据失去焦点来淡去主题色使其更加原生。

可控的焦点区域

我自己会有个习惯使用Tab键和Shift + Tab来切换焦点,保障一定的脱离鼠标的可用性,这样会提升使用体验。

通过自己维护一组焦点列表状态,根据当前焦点来判断上一个和下一个焦点,保障每次的焦点区域都在可控的访问内。

export type FocusArea = 'left'
  | 'middle'
  | 'content'
  | 'title'
  | 'leftInput'
  | 'searchInput'
  | 'findInput'
  | 'replaceInput'
  | 'sideMenu'
  | 'midDrag'
  | 'leftDrag'
  | 'dialog'

// 其中一个焦点区域的处理逻辑
titleInput.onfocus = () => {
  if (this.focusSource.focusState.value !== 'title') {
    this.focusSource.focusState.value = 'title'
  }
}
const focusChangeHandler = () => {
    const focus = this.focusSource.focusState.value
    if (focus !== 'title') { return }
    if (isCurrentFocusElement(titleInput)) { return }
    titleInput.focus()
}
this.focusSource.focusState.subscriptChange(focusChangeHandler)

原生右键菜单

所有的右键菜单使用原生的 context menu ,统一用户体验。

const menus = Menu.buildFromTemplate(menuTemp)
const content = BrowserWindow.fromWebContents(event.sender)
if (content === null) { return }
menus.popup({ window: content, x: Math.round(x), y: Math.round(y) })

目前的话,还有很多的额地方等待完善,我还不是很满意这个应用的使用感觉,很多地方使用起来比较生涩,主要是还有一些地方的动画还没有做完,以及性能上的优化还没没完成。

仿的原生始终不是原生,最终的理想还是等有足够的时间和收入了,将其彻底迁到 swift 或者 c++,更加轻量的应用体积和节能环保谁不喜欢呢。不过 js 的优化极限我还没有彻底发挥,先一步一个脚印吧。

2595 次点击
所在节点    分享创造
16 条回复
136178128
195 天前
文章不错。
你这个“仿原生风格”的时间应该已经足够使用 cursor 开发出你想要的功能了。
用 cursor 开发这类应用应该不算特别复杂(我也是 swift 小白,目前用 cursor 开发了好几个 swift 写的小工具了)
june4
195 天前
不喜欢 react 很正常,但纯手撸一个高动态 webapp 就有点抽象了,建议试试 solidjs
xipuxiaoyehua
195 天前
UI 和 MWeb 有些相似
zhouyg
195 天前
原生风格的 app 使用起来天然就感觉很“趁手”
R4rvZ6agNVWr56V0
195 天前
有点意思,但是重点应该是原生系统风格的 uikit 吧
w88975
195 天前
swift ui 开发 macos app 的唯一缺点就是, 文档太少

我之前开发一款 macos 桌面应用,最开始选型 flutter ,react-native

flutter 的缺点是 dart 语言,语法难受就不说了,UI 写法和无处不在的 context ,实在有点反人类,放弃了。

react-native 的优点是开发快,但是对于桌面端的支持太少,很多实现都需要自己写原生代码。

flutter 和 react-native 开发 macos 应用都有的缺点就是,写出来的 UI 跟 macos 风格差太多,即使做到风格类似,但是动画以及一些交互还是达不到,特别是这俩框架,mobile 为主流,写 macos app 就像开发游戏,在一个画板里画画,想实现多窗口交互,各类弹出式菜单,很难。

swift-ui , 虽然写起来没有 js 那么心智低,但是比 dart 好太多,不用考虑如何兼容 macos 的风格组件,天生支持各类交互动画,但是文档是真的难找又难懂,但算是唯一比较好的选择了。

当然,我指的是 macos 环境
zoharSoul
195 天前
@june4 #2 响应式的 ui 心智负担特别大, 老是要思考怎么不会 rebuild 多了...写起了头疼
比如 compose 就没 Android 之前的写起来省脑子
InAndOut
195 天前
没懂 你 electron 是 JS 的啊 我看你的代码感觉不是 JS 啊
calmbinweijin
194 天前
@InAndOut 这是 JS 啊,我的 Bro
musi
194 天前
@InAndOut 有没有可能是 ts
op 还标了一个// preload.ts
xieqiqiang00
194 天前
圆角看着不太对
ChrisFreeMan
194 天前
@w88975 我当时开发第一款 swift UI 桌面应用的时候,那个时候是 swift UI 3.0 吧,开发体验真的是难受,很多想要实现的桌面应用简单功能无法实现,比如控制多个元素的焦点事件,元素拖拽到屏幕边缘控制视图滚动,等等。文档那个时候也很垃圾,没有代码示例,文档像是源代码注释生成的。最糟糕的是 xcode 的编辑体验真的劝退,还是强制使用,高内存占用,预览缓慢,卡死,间断性失效。编辑器本身经常莫名崩溃。还有就是 swift 编译优化问题,经常因为一些出乎意料的语法使用问题,使其编译巨慢,还没有提示,最后花了很久才找到问题。太糟心了,我遇到的问题说不完。。。
ChrisFreeMan
194 天前
@zoharSoul 是的,高度的抽象只会使问题更难被发现。
ChrisFreeMan
194 天前
@GeekGao 你说的是 AppKit 吧,UIKit 应该指的是 iOS 和 iPadOS ,桌面端的是 swiftUI ,Appkit ,Cocoa 。其实我到现在都没分清楚它们之间的联系。
lizhenda
194 天前
厉害哈,要让用户觉得是在使用原生应用,而不是个浏览器,细节确实很多,一不注意就被察觉到违和。
ChrisFreeMan
194 天前
@lizhenda 现在还是有点粗糙,很多地方偷了懒动画没有打磨好,暂时先做到这种程度了,看看之后的用户量,不然不是很想进一步打磨了。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1113891

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX