V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
ChrisFreeMan
V2EX  ›  分享创造

仿原生风格 electron 应用的挑战

  •  2
     
  •   ChrisFreeMan · 35 天前 · 2139 次点击
    这是一个创建于 35 天前的主题,其中的信息可能已经有所发展或是发生改变。

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

    分享要点

    • App 展示
    • 技术栈
    • UI 设计和布局
    • 交互

    App 展示

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

    iamge1

    为什么选择非原生开发

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

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

    界面与布局

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

    Group 16.png

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

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

    Frame 13.png

    Screenshot 2025-02-20 at 7.32.18 PM.png

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

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

    Screenshot 2025-02-16 at 2.52.03 PM.png

    自适应黑暗模式

    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))
      }
    }
    

    Screenshot 2025-02-16 at 3.02.57 PM.png

    以及其他一些细节打磨,比如适当的动画过度效果。(因为没有找到合适的 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'
        },
    

    Screenshot 2025-02-16 at 3.42.33 PM.png

    独立的设置窗口

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

    可控的焦点区域

    我自己会有个习惯使用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)
    

    Frame 14.png

    原生右键菜单

    所有的右键菜单使用原生的 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) })
    

    Screenshot 2025-02-16 at 4.04.40 PM.png

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

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

    16 条回复    2025-02-25 19:58:15 +08:00
    136178128
        1
    136178128  
       35 天前
    文章不错。
    你这个“仿原生风格”的时间应该已经足够使用 cursor 开发出你想要的功能了。
    用 cursor 开发这类应用应该不算特别复杂(我也是 swift 小白,目前用 cursor 开发了好几个 swift 写的小工具了)
    june4
        2
    june4  
       35 天前
    不喜欢 react 很正常,但纯手撸一个高动态 webapp 就有点抽象了,建议试试 solidjs
    xipuxiaoyehua
        3
    xipuxiaoyehua  
       35 天前
    UI 和 MWeb 有些相似
    zhouyg
        4
    zhouyg  
       35 天前
    原生风格的 app 使用起来天然就感觉很“趁手”
    R4rvZ6agNVWr56V0
        5
    R4rvZ6agNVWr56V0  
       35 天前
    有点意思,但是重点应该是原生系统风格的 uikit 吧
    w88975
        6
    w88975  
       35 天前   ❤️ 1
    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
        7
    zoharSoul  
       35 天前
    @june4 #2 响应式的 ui 心智负担特别大, 老是要思考怎么不会 rebuild 多了...写起了头疼
    比如 compose 就没 Android 之前的写起来省脑子
    InAndOut
        8
    InAndOut  
       35 天前
    没懂 你 electron 是 JS 的啊 我看你的代码感觉不是 JS 啊
    calmbinweijin
        9
    calmbinweijin  
       34 天前
    @InAndOut 这是 JS 啊,我的 Bro
    musi
        10
    musi  
       34 天前
    @InAndOut 有没有可能是 ts
    op 还标了一个// preload.ts
    xieqiqiang00
        11
    xieqiqiang00  
       34 天前 via Android
    圆角看着不太对
    ChrisFreeMan
        12
    ChrisFreeMan  
    OP
       34 天前
    @w88975 我当时开发第一款 swift UI 桌面应用的时候,那个时候是 swift UI 3.0 吧,开发体验真的是难受,很多想要实现的桌面应用简单功能无法实现,比如控制多个元素的焦点事件,元素拖拽到屏幕边缘控制视图滚动,等等。文档那个时候也很垃圾,没有代码示例,文档像是源代码注释生成的。最糟糕的是 xcode 的编辑体验真的劝退,还是强制使用,高内存占用,预览缓慢,卡死,间断性失效。编辑器本身经常莫名崩溃。还有就是 swift 编译优化问题,经常因为一些出乎意料的语法使用问题,使其编译巨慢,还没有提示,最后花了很久才找到问题。太糟心了,我遇到的问题说不完。。。
    ChrisFreeMan
        13
    ChrisFreeMan  
    OP
       34 天前
    @zoharSoul 是的,高度的抽象只会使问题更难被发现。
    ChrisFreeMan
        14
    ChrisFreeMan  
    OP
       34 天前
    @GeekGao 你说的是 AppKit 吧,UIKit 应该指的是 iOS 和 iPadOS ,桌面端的是 swiftUI ,Appkit ,Cocoa 。其实我到现在都没分清楚它们之间的联系。
    lizhenda
        15
    lizhenda  
       34 天前
    厉害哈,要让用户觉得是在使用原生应用,而不是个浏览器,细节确实很多,一不注意就被察觉到违和。
    ChrisFreeMan
        16
    ChrisFreeMan  
    OP
       34 天前
    @lizhenda 现在还是有点粗糙,很多地方偷了懒动画没有打磨好,暂时先做到这种程度了,看看之后的用户量,不然不是很想进一步打磨了。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1030 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 19:00 · PVG 03:00 · LAX 12:00 · JFK 15:00
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.