• electron 起步


    electron 起步

    为什么要学 Electron,因为公司需要调试 electron 的应用。

    Electron 是 nodechromium 的结合体,可以使用 JavaScript,HTML 和 CSS 等 Web 技术创建桌面应用程序,支持 Mac、Window 和 Linux 三个平台。

    electron 的成功案例有许多,比如大名鼎鼎的 vscode

    hello-world

    官网有个快速启动的应用程序,我们将其下载到本地运行看一下。

    # Clone this repository
    git clone https://github.com/electron/electron-quick-start
    # Go into the repository
    cd electron-quick-start
    # Install dependencies
    npm install
    # Run the app
    npm start
    

    :运行 npm i 时卡在> node install.js,许久后报错如下

    $ npm i
    
    > electron@20.1.0 postinstall electron\electron-quick-start\node_modules\electron
    > node install.js
    
    RequestError: read ECONNRESET
        at ClientRequest. (electron\electron-quick-start\node_modules\got\source\request-as-event-emitter.js:178:14)
        at Object.onceWrapper (events.js:422:26)
        at ClientRequest.emit (events.js:327:22)
        at ClientRequest.origin.emit (electron\electron-quick-start\node_modules\@szmarczak\http-timer\source\index.js:37:11)
        at TLSSocket.socketErrorListener (_http_client.js:432:9)
        at TLSSocket.emit (events.js:315:20)
        at emitErrorNT (internal/streams/destroy.js:84:8)
        at processTicksAndRejections (internal/process/task_queues.js:84:21)
    npm ERR! code ELIFECYCLE
    npm ERR! errno 1
    npm ERR! electron@20.1.0 postinstall: `node install.js`
    npm ERR! Exit status 1
    npm ERR!
    npm ERR! Failed at the electron@20.1.0 postinstall script.
    npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
    

    网上搜索 postinstall: node install.js 的解决办法是将 electron 下载地址指向 taobao 镜像:

    // 将electron下载地址指向taobao镜像
    $ npm config set electron_mirror "https://npm.taobao.org/mirrors/electron/"
    

    新建项目 electron-demo 并参考 electron-quick-start

    // 新建文件夹
    $ mkdir electron-demo
    // 进入项目
    $ cd electron-demo
    // 初始化项目
    $ npm init -y
    // 安装依赖包
    $ npm i -D electron
    

    新建 electron 入口文件 index.js

    // electorn-demo/index.js
    const { app, BrowserWindow } = require('electron')
    const path = require('path')
    
    function createWindow() {
        const mainWindow = new BrowserWindow({
            width: 800,
            height: 600,
        })
        // 加载html页面
        mainWindow.loadFile('index.html')
    }
    
    app.whenReady().then(() => {
        createWindow()
    })
    

    新建一个 html 页面(例如 index.html):

    DOCTYPE html>
    
    
        
        
        
        Electron 网页
    
    
        hello world
    
    
    

    在 package.json 中增加启动 electron 的命令:

    {
      "name": "electron-demo",
      "version": "1.0.0",
      "description": "",
      "main": "index.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        // 会运行 main 字段指向的 indx.js 文件
      + "start": "electron ."
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "devDependencies": {
        "electron": "^20.1.1"
      }
    }
    

    启动 electron 应用:

    $ npm run start
    

    electron-helloworld.png

    Tip:windows 下通过 ctrl+shift+i 可以打开调试界面。或使用 mainWindow.webContents.openDevTools()也可以打开。

        // 加载html页面
        mainWindow.loadFile('index.html')
        // 默认打开调试工具
    +   mainWindow.webContents.openDevTools()
    

    electron-helloworld2.png

    热加载

    现在非常不利于调试:修改 index.jsindex.html 需要新运行 npm run start 才能看到效果。

    可以 electron-reloader 来解决这个问题。只要已修改代码,无需重启就能看到效果。

    首先安装依赖包,然后修改 electron 入口文件即可:

    $ npm i -D electron-reloader
    npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@~2.3.2 (node_modules\chokidar\node_modules\fsevents):
    npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.3.2: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})
    npm WARN electron-demo@1.0.0 No description
    npm WARN electron-demo@1.0.0 No repository field.
    
    + electron-reloader@1.2.3
    added 30 packages from 19 contributors and audited 109 packages in 26.217s
    
    20 packages are looking for funding
      run `npm fund` for details
    
    found 1 moderate severity vulnerability
      run `npm audit fix` to fix them, or `npm audit` for details
    
    

    electron 入口文件增加如下代码:

    $ git diff index.js
     const { app, BrowserWindow } = require('electron')
     const path = require('path')
    // 参考 npmjs 包
    +try {
    +       require('electron-reloader')(module);
    +} catch {}
    +
    

    重启服务后,再次修改 index.jsindex.html 等文件,只要保存就会自动看到效果。

    主进程和渲染进程

    官方api中有Main Process 模块Renderer Process 模块,比如我们在 hello-world 示例中使用的 BrowserWindow 标记了主进程,什么意思?

    • 主进程,通常是指 main.js 文件,是每个 Electron 应用的入口文件。控制着整个应用的生命周期,从打开到关闭。主进程负责创建 APP 的每一个渲染进程。一个 electron 应用有且只有一个主线程。
    • 渲染进程是应用中的浏览器窗口。 与主进程不同,渲染进程可能同时存在多个
    • 如果一个api同时属于两个进程,这里会归纳到 Main Process 模块
    • 如果在渲染进程中需要使用主进程的 api,需要通过 remote 的方式(下面会用到)。

    electorn-api.png

    菜单

    通过 Menu.setApplicationMenu 新增菜单。代码如下:

    // index.js
    require('./menu.js')
    
    // menu.js
    const { BrowserWindow, Menu } = require('electron')
    const template = [
    
        {
            id: '1', label: 'one',
            submenu: [
                {
                    label: '新开窗口',
                    click: () => { 
                        const ArticlePage = new BrowserWindow({
                            width: 500,
                            height: 500,
                        })
                        ArticlePage.loadFile('article.html')
                    }
                },
            ]
        },
        { id: '2', label: 'two' },
    ]
    const menu = Menu.buildFromTemplate(template)
    Menu.setApplicationMenu(menu)
    

    效果如下图所示:

    electron-menu.png

    :新增窗口的菜单和主窗口的菜单相同。

    自定义菜单

    比如酷狗音乐,导航是比较好看。做法是隐藏原生菜单,用自己的 html 代替。

    下面我们将原生菜单功能改为自定义菜单。

    首先通过 frame 将应用的边框去除,原始菜单也不再可见:

    const mainWindow = new BrowserWindow({
        // frame boolean (可选) - 设置为 false 时可以创建一个无边框窗口 默认值为 true。
        // 去除边框,菜单也不可见了
      + frame: false,
        width: 1500,
        height: 500,
    })
    

    Tip: frame boolean (可选) - 设置为 false 时可以创建一个无边框窗口 默认值为 true

    接着在 html 页面实现自己的菜单。例如:

    class="nav"> <span class="j-new">新建窗口span> <span>菜单2span>

    原始的导航是可以通过鼠标拖动。

    electron-menu2.png

    我们可以使用 -webkit-app-region 来增加可拖拽效果:

    
    

    :需要给菜单关闭拖拽效果,否则 cursor: pointer 会失效,点击也没反应,无法继续进行。

    接下来给菜单添加事件,点击时打开新窗口。

    这里得使用 BrowserWindow,则需要使用 require

    直接使用 require 会报错 require is not defined。需要在主进程中开启:

    const mainWindow = new BrowserWindow({
            webPreferences: {
                // 否则报错:require is not defined
              + nodeIntegration: true,
                // 在新版本的electron中由于安全性的原因还需要设置 contextIsolation: false
              + contextIsolation: false,
            },
        })
    

    接下来就得在引入 BrowserWindow,相当于在渲染进程中使用主进程的 api,需要使用 remote。如果这么用则会报错 const BrowserWindow = require("electron").remote.BrowserWindow,网友说:electron12 中已经废弃了remote 模块,如果需要则需自己安装 @electron/remote。

    进入 npmjs 中搜索 @electron/remote,用于连接主进程到渲染进程的 js 对象:

    // @electron/remote是Electron 模块,连接主进程到渲染进程的 js 对象
    
    @electron/remote is an Electron module that bridges JavaScript objects from the main process to the renderer process. This lets you access main-process-only objects as if they were available in the renderer process.
    
    // @electron/remote是electron内置远程模块的替代品,该模块已被弃用,最终将被删除。
    
    @electron/remote is a replacement for the built-in remote module in Electron, which is deprecated and will eventually be removed.
    

    用法如下:

    // 安装依赖
    $ npm install --save @electron/remote
    
    // 在主进程中进行初始化
    require('@electron/remote/main').initialize()
    require("@electron/remote/main").enable(mainWindow.webContents);
    
    // 渲染进程
    const { BrowserWindow } = require('@electron/remote')
    

    核心页面的完整代码如下:

    • 主进程页面:
    // index.js 入口文件(主进程)
    
    const { app, BrowserWindow} = require('electron')
    // 在主进程中进行初始化,然后才能从渲染器中使用
    require('@electron/remote/main').initialize()
    
    // 热加载
    try {
        require('electron-reloader')(module);
    } catch { }
    
    function createWindow() {
        const mainWindow = new BrowserWindow({
           
            // 去除边框,菜单也不可见了
            frame: false,
            width: 1500,
            height: 500,
            webPreferences: {
                // 否则报错:require is not defined
                nodeIntegration: true,
                // 在新版本的electron中由于安全性的原因还需要设置 contextIsolation: false
                contextIsolation: false,
                // 不需要 enableremoteModule,remote 也生效
                // enableremoteModule: true
            },
        })
        // 在electron >= 14.0.0中,您必须使用新的enableAPI 分别为每个所需的远程模块启用WebContents
        // 笔者:"electron": "^20.1.1"
        require("@electron/remote/main").enable(mainWindow.webContents);
    
        // 加载html页面
        mainWindow.loadFile('index.html')
    }
    
    app.whenReady().then(() => {
        createWindow()
    })
    
    // 加载菜单
    require('./menu.js')
    
    • 渲染进程页面
    
    DOCTYPE html>
    
    
        
        
        
        Electron 网页
        
    
    
        
        
        hello world2
        
    
    
    
    // index-js.js 渲染进程
    
    // 渲染窗口使用 require 浏览器报错:Uncaught ReferenceError: require is not defined
    const { BrowserWindow } = require('@electron/remote')
    
    const elem = document.querySelector('.j-new')
    elem.onclick = function(){
        const ArticlePage = new BrowserWindow({
            width: 500,
            height: 500,
        })
        ArticlePage.loadFile('article.html')
    }
    

    打开浏览器

    现在需要点击自定义菜单中的百度,然后打开浏览器并跳转到百度。

    这里主要用到 shell,它属于主进程也属于渲染进程,所以这里无需使用 remote 方式引入。首先在 index.html 中增加 a 标签,然后在 js 中注册点击事件,最后调用 shell.openExternal 即可。请看代码:

    class="nav"> <span class="j-new">新建窗口span> <span><a href='https://www.baidu.com'>百度a>span>

    // index-js.js
    const { shell} = require('electron')
    // 渲染窗口使用 require 浏览器报错:Uncaught ReferenceError: require is not defined
    const { BrowserWindow } = require('@electron/remote')
    
    ...
    const aElem = document.querySelectorAll('a');
    
    [...aElem].forEach(item => item.onclick=(e)=>{
        // 防止主进程打开页面
        e.preventDefault()
        const url = e.target.getAttribute('href');
        shell.openExternal(url)
    })
    

    点击百度,会打开本地默认浏览器。

    Tip:如果不要 http 直接写成 www.baidu.com,笔者测试失败。

    文件读取和保存

    先看要实现的效果:
    electron-file.png

    点击读取,选择文件后,文件内容会显示到 textarea 中,对 textarea 进行修改文案,点击保存,输入要保存的文件名即可保存。

    这里需要使用主进程的 dialog api。由于 dialog 属于主进程,要在渲染进程中使用,则需要使用 remote。

    dialog - 显示用于打开和保存文件、警报等的本机系统对话框。这里用到两个 api 分别用于打开读取文件和保存文件的系统窗口:

    • dialog.showOpenDialogSync,打开读取文件的系统窗口,可以定义标题、确定按钮的文本、指定可显示文件的数组类型等等,点击保存,返回用户选择的文件路径。接下来就得用 node 去根据这个路径读取文件内容
    • dialog.showSaveDialogSync,与读文件类似,这里是保存,返回要保存的文件路径

    Tip:真正读取文件和保存文件还是需要使用 node 的 fs 模块。有关 node 的读写文件可以参考这里

    核心代码如下:

    // index-js.js
    const fs = require('fs')
    const { BrowserWindow, dialog } = require('@electron/remote')
    ...
    
    // 文件操作
    const readElem = document.querySelector('.j-readFile')
    const textarea = document.querySelector('textarea')
    const writeElem = document.querySelector('.j-writeFile')
    // 读文件
    readElem.onclick = function () {
        // 返回 string[] | undefined, 用户选择的文件路径,如果对话框被取消了 ,则返回undefined。
        const paths = dialog.showOpenDialogSync({ title: '选择文件', buttonLabel: '自定义确定' })
        console.log('paths', paths)
        fs.readFile(paths[0], (err, data) => {
            if (err) throw err;
            textarea.value = data.toString()
            console.log(data.toString());
        });
    }
    
    // 写文件
    writeElem.onclick = function () {
        // 返回 string | undefined, 用户选择的文件路径,如果对话框被取消了 ,则返回undefined。
        const path = dialog.showSaveDialogSync({ title: '保存文件', buttonLabel: '自定义确定' })
        console.log('path', path)
        // 读取要保存的内容
        const data = textarea.value
        fs.writeFile(path, data, (err) => {
            if (err) throw err;
            console.log('The file has been saved!');
        });
    }
    

    注册快捷键

    比如 vscode 有快捷键,这里我们也给加上。比如按 ctrl+m 时,最大化或取消最大化窗口。

    这里需要使用 globalShortcut(主进程 ):在应用程序没有键盘焦点时,监听键盘事件。

    用法很简单,直接注册即可。请看代码:

    const { app, BrowserWindow, globalShortcut} = require('electron')
    
    app.whenReady().then(() => {
        const mainWindow = createWindow()
        // 注册快捷键
        globalShortcut.register('CommandOrControl+M', () => {
            // 主进程会在 node 中输出,而非浏览器
            console.log('CommandOrControl+M is pressed')
            // 如果是不是最大,就最大化,否则取消最大化
            !mainWindow.isMaximized() ? mainWindow.maximize() : mainWindow.unmaximize()
        })
    })
    

    渲染进程中注册快捷键也类似,不过需要通过 remote 的方式引入。就像这样:

    const { BrowserWindow, dialog, globalShortcut } = require('@electron/remote')
    
    // 渲染进程中注册快捷键
    globalShortcut.register('CommandOrControl+i', () => {
        // 主进程会在 node 中输出,而非浏览器
        console.log('CommandOrControl+i is pressed')
    })
    

    主线程和渲染进程通信

    需求:在渲染进程中点击一个按钮,来触发主线程的放大或取消放大功能。

    需要使用以下两个 api:

    • ipcMain 主进程 - 从主进程到渲染进程的异步通信。
    • ipcRenderer 渲染进程 - 从渲染器进程到主进程的异步通信。

    Tip:在 Electron 中,进程使用 ipcMain 和 ipcRenderer 模块,通过开发人员定义的“通道”传递消息来进行通信。 这些通道是 任意 (您可以随意命名它们)和 双向 (您可以在两个模块中使用相同的通道名称)的。

    首先在主进程注册事件:

    ipcMain.on('max-unmax-event', (evt, ...args) => {
        // max-unmax-event。args= [ 'pjl' ]
        console.log('max-unmax-event。args=', args)
    })
    

    接着在 index.html 中增加最大化/取消最大化按钮:

    class="nav"> <span class="j-new">新建窗口span> + <span class="j-max">最大化/取消最大化span>

    最后在渲染进程中,点击按钮时通过 ipcRenderer 触发事件,并可传递参数。就像这样

    // 最大化/取消最大化
    const maxElem = document.querySelector('.j-max')
    maxElem.onclick = function() {
        ipcRenderer.send('max-unmax-event', 'pjl')
        console.log('max')
    }
    

    打包

    打包可以借助 electron-packager 来完成。

    // 安装
    $ npm install --save-dev electron-packager
    
    // 用法
    npx electron-packager   --platform= --arch= [optional flags...]
    
    // 就像这样
    "build": "electron-packager ./ electron-demo --platform=win32 --arch=x64 ./outApp --overwrite --icon=./favicon.ico"
    

    笔者环境是 node13.14,构建失败:

    $ npm run build
    
    > electron-demo@1.0.0 build electron\electron-demo
    > electron-packager ./ electron-demo --platform=win32 --arch=x64 ./outApp --overwrite --icon=./favicon.ico
    
    CANNOT RUN WITH NODE 13.14.0
    Electron Packager requires Node >= 14.17.5.
    npm ERR! code ELIFECYCLE
    ...
    

    react/vue 中使用 electron

    用法很简单,首先准备好 react 或 vue 项目,笔者就以多次使用的 react(spug) 项目来进行。

    安装 electron 包:

    $ npm i -D electron
    

    在 package.json 中增加运行命令,指定 electron 入口文件:

    $ git diff package.json
       "name": "spug_web",
    +  "main": "electron-main.js",
        "scripts": {
            "test": "react-app-rewired test",
            "eject": "react-scripts eject",
    +       "electron": "electron ."
        },
       "devDependencies": {
    +    "electron": "^20.1.2",
         
    

    编写 electron 入口文件如下,其中 loadFile 要改为 loadURL

    // electron-main.js
    const { app, BrowserWindow } = require('electron')
    const path = require('path')
    
    function createWindow() {
        const mainWindow = new BrowserWindow({
            width: 800,
            height: 600,
        })
        // loadFile 改为 loadURL
        mainWindow.loadURL('http://localhost:3000/')
    }
    
    app.whenReady().then(() => {
        createWindow()
    })
    
    // 启动 react 项目,在浏览器中能通过 http://localhost:3000/ 正常访问
    $ npm run start 
    // 启动 electron
    $ npm run electron
    

    正常的话, electron 中就能看到 react 的项目。效果如下:

    electron-spug.png

    Tip:如果发布的话,首先通过 react 构建,例如输出 dir,然后将 loadURL 改为 loadFile('./dir/index.html')

    完整代码

    index.js

    // index.js 入口文件(主进程)
    
    const { app, BrowserWindow, globalShortcut, ipcMain} = require('electron')
    // 在主进程中进行初始化,然后才能从渲染器中使用
    require('@electron/remote/main').initialize()
    
    // 热加载
    try {
        require('electron-reloader')(module);
    } catch { }
    
    function createWindow() {
        const mainWindow = new BrowserWindow({
           
            // frame boolean (可选) - 设置为 false 时可以创建一个无边框窗口 默认值为 true。
            // 去除边框,菜单也不可见了
            frame: false,
            width: 1500,
            height: 500,
            webPreferences: {
                // 否则报错:require is not defined
                nodeIntegration: true,
                // 在新版本的electron中由于安全性的原因还需要设置 contextIsolation: false
                contextIsolation: false,
                // 不需要 enableremoteModule,remote 也生效
                // enableremoteModule: true
            },
        })
        // 在electron >= 14.0.0中,您必须使用新的enableAPI 分别为每个所需的远程模块启用WebContents
        // 笔者:"electron": "^20.1.1"
        require("@electron/remote/main").enable(mainWindow.webContents);
    
        // 加载html页面
        mainWindow.loadFile('index.html')
        return mainWindow
    }
    
    app.whenReady().then(() => {
        const mainWindow = createWindow()
        // 注册快捷键
        globalShortcut.register('CommandOrControl+M', () => {
            // 主进程会在 node 中输出,而非浏览器
            console.log('CommandOrControl+M is pressed')
            // 如果是不是最大,就最大化,否则取消最大化
            !mainWindow.isMaximized() ? mainWindow.maximize() : mainWindow.unmaximize()
        })
    })
    
    // 加载菜单
    require('./menu.js')
    
    ipcMain.on('max-unmax-event', (evt, ...args) => {
        console.log('max-unmax-event。args=', args)
    })
    

    index.html

    
    DOCTYPE html>
    
    
        
        
        
        Electron 网页
        
    
    
        
        
        

    hello world

    文件操作

    index-js.js

    // index-js.js
    const { shell, ipcRenderer } = require('electron')
    const fs = require('fs')
    // 渲染窗口使用 require 浏览器报错:Uncaught ReferenceError: require is not defined
    const { BrowserWindow, dialog, globalShortcut } = require('@electron/remote')
    
    const elem = document.querySelector('.j-new')
    elem.onclick = function () {
        const ArticlePage = new BrowserWindow({
            width: 500,
            height: 500,
        })
        ArticlePage.loadFile('article.html')
    }
    
    const aElem = document.querySelectorAll('a');
    
    [...aElem].forEach(item => item.onclick = (e) => {
        e.preventDefault()
        const url = e.target.getAttribute('href');
        console.log('href', url)
    
        shell.openExternal(url)
    })
    
    // 文件操作
    const readElem = document.querySelector('.j-readFile')
    const textarea = document.querySelector('textarea')
    const writeElem = document.querySelector('.j-writeFile')
    // 读文件
    readElem.onclick = function () {
        // 返回 string[] | undefined, 用户选择的文件路径,如果对话框被取消了 ,则返回undefined。
        const paths = dialog.showOpenDialogSync({ title: '选择文件', buttonLabel: '自定义确定' })
        console.log('paths', paths)
        fs.readFile(paths[0], (err, data) => {
            if (err) throw err;
            textarea.value = data.toString()
            console.log(data.toString());
        });
    }
    
    // 写文件
    writeElem.onclick = function () {
        // 返回 string | undefined, 用户选择的文件路径,如果对话框被取消了 ,则返回undefined。
        const path = dialog.showSaveDialogSync({ title: '保存文件', buttonLabel: '自定义确定' })
        console.log('path', path)
        // 读取要保存的内容
        const data = textarea.value
        console.log('data', data)
        fs.writeFile(path, data, (err) => {
            if (err) throw err;
            console.log('The file has been saved!');
        });
    }
    
    // 渲染进程中注册快捷键
    globalShortcut.register('CommandOrControl+i', () => {
        // 主进程会在 node 中输出,而非浏览器
        console.log('CommandOrControl+i is pressed')
        // mainWindow.webContents.openDevTools()
        // // 如果是不是最大,就最大化,否则取消最大化
        // !mainWindow.isMaximized() ? mainWindow.maximize() : mainWindow.unmaximize()
    })
    
    // 最大化/取消最大化
    const maxElem = document.querySelector('.j-max')
    maxElem.onclick = function() {
        ipcRenderer.send('max-unmax-event', 'pjl')
        console.log('max')
    }
    
    // menu.js
    const { BrowserWindow, Menu } = require('electron')
    const template = [
    
        {
            id: '1', label: 'one',
            submenu: [
                {
                    label: '新开窗口',
                    click: () => { 
                        const ArticlePage = new BrowserWindow({
                            width: 500,
                            height: 500,
                        })
                        ArticlePage.loadFile('article.html')
                    }
                },
            ]
        },
        { id: '2', label: 'two' },
    ]
    const menu = Menu.buildFromTemplate(template)
    Menu.setApplicationMenu(menu)
    

    package.json

    {
      "name": "electron-demo",
      "version": "1.0.0",
      "description": "",
      "main": "index.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "start": "electron .",
        "build": "electron-packager ./ electron-demo --platform=win32 --arch=x64 ./outApp --overwrite --icon=./favicon.ico"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "devDependencies": {
        "electron": "^20.1.1",
        "electron-packager": "^16.0.0",
        "electron-reloader": "^1.2.3"
      },
      "dependencies": {
        "@electron/remote": "^2.0.8"
      }
    }
    
    
  • 相关阅读:
    多测师肖sir_高级金牌讲师__接口测试之练习题(6.1)
    vue中插槽的详解
    MySQL日志管理、备份与恢复
    【Docker 内核详解】namespace 资源隔离(一):进行 namespace API 操作的 4 种方式
    缓存神器-JetCache
    Kubernates 1.24.3 操作
    企业级存储详解与存储资源盘活
    项目采购管理
    在全志V853开发板试编译QT测试
    每晚坚持10点前睡觉,半年后有什么变化?感觉自己年轻好几岁
  • 原文地址:https://www.cnblogs.com/pengjiali/p/16671784.html