分类: 编程相关

  • 微信小程序开发 MCP

    https://github.com/yfmeii/weapp-dev-mcp

    https://www.npmjs.com/package/@yfme/weapp-dev-mcp

    起因是前几天微信开放社区说微信开发者工具 2.0 发布了,据说有增强 AI 协作能力的封装

    但是事实是并没有找到任何暴露 MCP 服务的迹象,社区也没有人做出回复

     

    那就只能自己动手了,小程序有一个自动化,是用来做自动化测试的,能通过编程的方式控制和读取运行状态下的小程序,也就是说设想中的完美开发方式,AI 完成编码后自己能走一遍交互能大大降低返工次数也能节省 AI 的使用成本

    当然有很多现成的别人做过的 MCP,大多都是以 cli 形式控制的,但是这样就存在一个问题,部分工具的 MCP 有沙箱机制,会导致 MCP 无法调用工作目录以外的文件,所以我这里主要使用 websocket 控制

    首先要打开微信开发者工具的自动化测试:

    1. 打开微信开发者工具
    2. 进入 设置 → 安全设置 → 服务端口
    3. 开启 “HTTP 调试” 和 “自动化测试”

     

    配置:

    {
      "mcpServers": {
        "weapp-dev": {
          "command": "npx",
          "args": [
            "-y",
            "-p", "@modelcontextprotocol/sdk@1.17.2",
            "-p", "fastmcp@3.23.0",
            "-p", "@yfme/weapp-dev-mcp",
            "weapp-dev-mcp"
          ],
          "env": {
            "WEAPP_WS_ENDPOINT": "ws://localhost:9420"
          }
        }
      }
    }

    启动微信开发者工具(macOS):

    /Applications/wechatwebdevtools.app/Contents/MacOS/cli auto --project /path/to/your/project --auto-port 9420

    其中:

    • --project 参数指定小程序项目目录路径(请替换为实际的项目路径)
    • --auto-port 参数指定 WebSocket 服务端口(默认 9420)

     

    提供工具:

    应用工具(Application Tools)

    • mp_ensureConnection – 确保自动化会话就绪;可选择强制重连或覆盖连接设置
    • mp_navigate – 在小程序内导航,支持 navigateToredirectToreLaunchswitchTab 或 navigateBack
    • mp_screenshot – 捕获屏幕截图并返回(或保存到磁盘)
    • mp_callWx – 调用微信小程序 API 方法(如 wx.showToast
    • mp_getLogs – 获取小程序控制台日志,可选择获取后清除

    页面工具(Page Tools)

    • page_getElement – 通过选择器获取页面元素
    • page_waitElement – 等待元素出现在页面上(⚠️ 不适用于自定义组件内部元素)
    • page_waitTimeout – 等待指定的毫秒数
    • page_getData – 获取当前页面的数据对象,可指定路径
    • page_setData – 使用 setData 更新当前页面的数据
    • page_callMethod – 调用当前页面实例上暴露的方法

    元素工具(Element Tools)

    • element_tap – 通过 CSS 选择器点击 WXML 元素
    • element_input – 向元素输入文本(适用于 input 和 textarea 组件)
    • element_callMethod – 调用自定义组件实例的方法(需要 ID 选择器)
    • element_getData – 获取自定义组件实例的渲染数据(需要 ID 选择器)
    • element_setData – 设置自定义组件实例的渲染数据(需要 ID 选择器)
    • element_getInnerElement – 获取元素内的元素(相当于 element.$(selector)
    • element_getInnerElements – 获取元素内的元素数组(相当于 element.$$(selector)
    • element_getSize – 获取元素大小(宽度和高度)
    • element_getWxml – 获取元素 WXML(内部或外部)
  • Swift 通过执行二进制文件增强功能

    Swift 使用 Process 可以执行二进制文件从而实现一定功能,例如下面的完整 图片压缩 例子

    pngquant 原本是没有 Swift 版本的,在我之前开发的的 macOS 原生 SwiftUI 版本切图上传工具中的图片压缩功能,手动实现起来极其复杂而且效果可能很差,所以使用了这种方法,目前稳定使用了数月

    大致步骤:

    1. 读取内置二进制文件 Bundle.main.path(forResource: "pngquant", ofType: nil)
    2. 创建 Process 实例 let process = Process()
    3. 在 Process 中指定二进制文件 process.executableURL = URL(fileURLWithPath: oxipngPath)
    4. 加入参数 process.arguments = ["--quality", "0-70", "--force", "-o", outputFilePath, inputFilePath]
    5. 创建管道 let pipe = Pipe(); process.standardOutput = pipe
    6. 启动进程 try process.run(); process.waitUntilExit()
    7. 非必要 读取输出_ = pipe.fileHandleForReading.readDataToEndOfFile()
    8. 在执行结束后进行下一步操作 if process.terminationStatus == 0 { // 结束 }
    
    import Foundation
    
    // 获取指定路径的文件大小
    func fileSize(atPath path: String) -> UInt64? {
        do {
            // 尝试获取指定路径文件的属性
            let attributes = try FileManager.default.attributesOfItem(atPath: path)
            // 返回文件大小属性(如果存在),并转换为 UInt64 类型
            return attributes[.size] as? UInt64
        } catch {
            // 如果无法获取文件属性,则打印错误信息
            print("无法获取文件大小: \(error)")
            return nil
        }
    }
    
    // 使用 pngquant 压缩文件并计算压缩效果
    func compress(url: String, outputPath: String) -> (originalFileSize: UInt64?, compressedFileSize: UInt64?, compressionRate: Double?) {
        // 尝试在应用程序包中找到 pngquant 二进制文件的路径
        guard let oxipngPath = Bundle.main.path(forResource: "pngquant", ofType: nil) else {
            fatalError("无法找到 pngquant 二进制文件")
        }
    
        // 使用 fileSize 函数获取原始文件大小
        guard let originalFileSize = fileSize(atPath: url) else {
            print("无法获取原始文件大小")
            return (nil, nil, nil)
        }
    
        // 设置 Process 以运行 pngquant 命令
        let process = Process()
        process.executableURL = URL(fileURLWithPath: oxipngPath)
        // 定义 pngquant 命令的参数
        process.arguments = ["--quality", "0-70", "--force", "-o", outputPath, url]
    
        do {
            // 尝试运行该进程
            try process.run()
            // 等待进程完成
            process.waitUntilExit()
    
            // 检查进程是否成功完成
            if process.terminationStatus == 0, let compressedFileSize = fileSize(atPath: outputPath) {
                // 计算压缩率(百分比)
                let compressionRate = Double(originalFileSize - compressedFileSize) / Double(originalFileSize) * 100
                // 返回原始大小、压缩后大小和压缩率
                return (originalFileSize, compressedFileSize, compressionRate.rounded())
            } else {
                // 如果优化失败,打印错误信息
                print("PNG 优化失败")
                return (originalFileSize, nil, nil)
            }
        } catch {
            // 如果进程启动失败,打印错误信息
            print("启动失败: \(error)")
            return (originalFileSize, nil, nil)
        }
    }
    

     

  • Golang 可选参数实现

    Golang 可选参数实现

    在部分方法例如公共方法中,为了平衡懒得填完参数和需要高度配置,又不想写那么多函数重载

    可以使用这个方式

    在这个方式下,可以在做这个方法参数的调整的同时不修改函数签名

    还能实现默认参数

    例子:

    // ServerOption 是一个用于配置 Server 的选项。
    type ServerOption struct {
    	f func(*Server)
    }
    
    // Server 表示服务器配置。
    type Server struct {
    	Address string
    	Port    int
    }
    
    // NewServer 创建一个新的 Server,并应用所有给定的配置选项。
    func NewServer(options ...ServerOption) *Server {
    	s := &Server{
    		Address: "localhost",
    		Port:    8080,
    	}
    	for _, option := range options {
    		option.f(s)
    	}
    	return s
    }
    
    // WithAddress 返回一个配置 Server 地址的选项。
    func WithAddress(address string) ServerOption {
    	return ServerOption{func(s *Server) {
    		s.Address = address
    	}}
    }
    
    // WithPort 返回一个配置 Server 端口的选项。
    func WithPort(port int) ServerOption {
    	return ServerOption{func(s *Server) {
    		s.Port = port
    	}}
    }
    
    func main() {
    	// 创建一个 Server,使用默认配置。
    	server1 := NewServer()
    	fmt.Printf("Server1: Address=%s, Port=%d\n", server1.Address, server1.Port)
    
    	// 创建一个 Server,配置自定义地址。
    	server2 := NewServer(WithAddress("192.168.1.1"))
    	fmt.Printf("Server2: Address=%s, Port=%d\n", server2.Address, server2.Port)
    
    	// 创建一个 Server,配置自定义端口。
    	server3 := NewServer(WithPort(9090))
    	fmt.Printf("Server3: Address=%s, Port=%d\n", server3.Address, server3.Port)
    
    	// 创建一个 Server,配置自定义地址和端口。
    	server4 := NewServer(WithAddress("10.0.0.1"), WithPort(8081))
    	fmt.Printf("Server4: Address=%s, Port=%d\n", server4.Address, server4.Port)
    }
  • Donut 与原生的两次相遇

    Donut 与原生的两次相遇

    在大概半年前,工作原因采用了微信新出的 Donut 平台,从其域名可以看出对微信的重要性

    Donut 是一个多端的应用程序开发方案,主要是通过微信小程序的开发方式开发移动应用程序,兼容 iOS 和 Android,当然对于应用程序来说,微信小程序的 API 和功能是不够的,尽管其推出了渲染性能和原生差别不大的 Skyline 渲染引擎

    小程序的开发方式对于第三方 SDK 或插件来说是较为不友好的,特别是在移动应用程序中,而且在初期版本中,连用户对这个应用的授权情况都无法得知,于是有了第一次相遇

    第一次相遇

    刚开始 Donut 框架还是提供原生工程的,在初期 API 的支持确实过于贫瘠,所以我们的考虑是首个版本需要快速上线,就采用以 Donut 为主要工程,使用原生补充其功能,后期采用原声工程嵌入 Donut 模块

    但是马上问题就随之而来,因为我们是早期的用户,所以 Donut 提供了技术支持,在将原生工程嵌入 Donut 项目的过程中,我们处处碰壁,在官方文档上也就只有简单的描述如何让小程序模块与原生工程通信

    在尝试寻求 Donut 的帮助的时候,他们告知将来将不会提供原生工程,也让我们不要使用原生

    随后不久官网说明也变成了下面这样子

     

    我不会原生开发,当时也是硬着头皮做

    但是适配工作已经开始了,在上线的 ddl 之前,Donut 显然无法做到我们能用的程度,我们还是硬着头皮继续研究我们的原生部分,在他们的工程中显然做了很大的限制,例如在 Android 中,小程序包启动完成后就销毁了对应的 Activity 等等

    具体踩坑过程就不再赘述

    第二次相遇

    在前段时间,Donut 告知我们,使用原生工程构建的应用将在 12月底停用,届时所有用户将无法使用应用

    于是我们又要做第二次的适配,当时我们的项目已是如此

    但是好在前不久,Donut 推出了原生插件,并且这次提供了更加完善的接入原生的文档和支持,这次我们打算抛弃 React Native 部分,仅保留难以迁移的之前适配好的原生部分

     

    我主要做 iOS 部分的适配,这里仅说明 iOS

    原生插件的工程是 OC 的,“得益” 于 OC 的语法,我不得不使用 Swift

    在 iOS 中,插件是一个 <pluginId>.framework

    使用 Swift,首先就是要创建一个桥文件,新建文件后,在 #import,但是无论怎么编译,就是找不到这个桥文件,最后翻遍了全网才看到官网上的描述,文档地址

    #import <ProductName/ProductModuleName-Swift.h>

    在解决完这个问题后终于能编译通过了

    良好的编程体验

    在原生插件中,分为同步方法和异步方法,都是直接暴露插件的原生函数,而定义函数又是需要在 OC 中进行,所以这及其影响体验

    所以我在 OC 中只暴露了 sync 和 async 两个方法

    // 声明插件同步方法
    WEAPP_EXPORT_PLUGIN_METHOD_SYNC(sync, @selector(sync:))
    
    // 声明插件异步方法
    WEAPP_EXPORT_PLUGIN_METHOD_ASYNC(async, @selector(async:withCallback:))
    
    - (id)sync:(NSDictionary *)param {
        NSLog(@"sync %@", param);
        
        // 提取方法名和数据
        NSString *methodName = param[@"name"];
        id data = param[@"data"] ?: nil;
    
        // 调用 invoke 方法,并传递提取出的方法名和数据
        return [self.nativeSync invoke:methodName withData:data];
    }
    
    - (void)async:(NSDictionary *)param withCallback:(WeAppNativePluginCallback)callback {
        NSLog(@"async %@", param);
    
        // 提取方法名和数据
        NSString *methodName = param[@"name"];
        id data = param[@"data"] ?: nil;
    
        // 调用 Swift 的异步 invoke 方法
        [self.nativeAsync invoke:methodName withData:data withCallback:^(id result) {
            // 处理回调结果,这里使用了一个示例回调数据
            NSDictionary *callbackData = @{ @"data": result };
            callback(callbackData);
        }];
    }
    
    @objcMembers
    class NativeSync: NSObject {
    
        public func invoke(_ methodName: String, withData data: Any?) -> NSDictionary {
            let selectorName = methodName + (data != nil ? "WithData:" : "")
            let selector = Selector(selectorName)
    
            if self.responds(to: selector) {
                let result = self.perform(selector, with: data)
                return result?.takeUnretainedValue() as? NSDictionary ?? RespObject(msg: "Error")
            } else {
                return RespObject(msg: "No such method")
            }
        }
        
        /*
         自定义原生同步函数编写规则:
         1. 函数接受参数必须为 data,且类型只能为 Any,类型判断在函数内进行
         2. 函数返回值必须为 NSDictionary,可以使用 RespObject 函数构建返回值
         */
    
        func someFunction(data: Any) -> NSDictionary {
            RespObject(data: "Result from someFunction \(data)")
        }
    }
    
    @objcMembers
    class NativeAsync: NSObject {
    
        public func invoke(_ methodName: String, withData data: Any?, withCallback callback: @escaping (NSDictionary) -> Void) {
            let selectorName = methodName + (data != nil ? "WithData:callback:" : "WithCallback:")
            let selector = Selector(selectorName)
            
            let block: @convention(block) (NSDictionary) -> Void = callback
    
            if self.responds(to: selector) {
                if let data = data {
                    self.perform(selector, with: data, with: block)
                } else {
                    self.perform(selector, with: block)
                }
            } else {
                callback(RespObject(msg: "\(selectorName) is not a valid method."))
            }
        }
        
        /*
         自定义原生异步函数编写规则:
         1. 函数接受参数必须为 data,且类型只能为 Any,类型判断在函数内进行
         2. 函数接受闭包必须为 callback,且类型只能为 (NSDictionary) -> Void,可以使用 RespObject 函数构建
         */
    
    
        func someAsyncFunction(callback: @escaping (NSDictionary) -> Void) {
            DispatchQueue.global().async {
                DispatchQueue.main.async {
                    callback(RespObject(data: "Result from someAsyncFunction"))
                }
            }
        }
    }
    
    var sendMsgClosure: ((Any) -> Void)?
    
    @objcMembers
    class NativeMsg: NSObject {
        static let shared = NativeMsg()
        
        private override init() { }
        
        class func configure(_ block: @escaping (Any) -> Void) {
            sendMsgClosure = block
        }
        
        class func sendMsg(_ name: String, with data: NSDictionary?) {
            var msg: [String: AnyHashable] = ["name": name]
            if data != nil {
                msg["data"] = data
            }
            
            sendMsgClosure?(msg)
        }
        
        class func sendMsg(_ name: String) {
            let msg: NSDictionary = ["name": name]
            
            sendMsgClosure?(msg)
        }
    
    }
    
    

    这样就能在 Swift 中实现所有原生逻辑了,当然在开发过程中还做了原生部分单方面对小程序发送消息,这是封装好的调用插件的东西,时间关系没有更深入封装

    export class Plugin {
        eventListeners = {};
    
        plugin = null;
        constructor(pluginId) {
            let that = this;
            console.log('init plugin')
            wx.miniapp.loadNativePlugin({
                pluginId,
                success: (res) => {
                    that.plugin = res
    
                    // 监听事件
                    res.onMiniPluginEvent((event) => {
                        const name = event.name;
                        const data = event.data;
    
                        // 根据事件名,找到对应的事件id
                        const eventIds = Object.keys(that.eventListeners).filter(eventId => {
                            return that.eventListeners[eventId].name === name;
                        });
    
                        // 根据事件id,找到对应的回调函数
                        eventIds.forEach(eventId => {
                            const callback = that.eventListeners[eventId].callback;
                            callback(data);
                        });
                    })
                },
                fail: (e) => {
                    console.log('load plugin fail', e)
                }
            })
        }
    
        async getPlugin() {
            let plugin = null
            if (this.plugin) {
                plugin = this.plugin
            } else {
                plugin = await new Promise((resolve, reject) => {
                    setTimeout(() => {
                        reject('timeout');
                    }, 1500);
                    let interval = setInterval(() => {
                        if (this.plugin) {
                            clearInterval(interval)
                            resolve(this.plugin)
                        }
                    }, 10)
                })
            }
    
            return plugin
        }
    
        add(name, callback) {
            // 随机取一个唯一的事件id
            const eventId = Math.random().toString(36).substr(2);
    
            // 在这个事件id下,存储需要监听的事件名和回调函数
            this.eventListeners[eventId] = {
                name,
                callback,
            };
    
            // 返回这个事件id,用于后续的移除事件监听
            return eventId;
        }
    
        once(name, callback) {
            // 用add方法添加事件监听
            const eventId = this.add(name, (data) => {
                // 执行回调函数
                callback(data);
    
                // 执行完回调函数后,移除事件监听
                this.remove(eventId);
            });
        }
    
        remove(eventId) {
            // 从eventListeners中删除这个事件id
            delete this.eventListeners[eventId];
        }
    }
    
    let pluginInstance = new Plugin('')
    
    
    export default pluginInstance

    最后

    抱着一开始的设想,如果能使用原生工程中嵌入小程序模块,对开发者来说无疑是最好的,有着优秀的拓展性和足够简单的开发方式,例如 Widget 等,如果只能在小程序工程中嵌入原生模块,就只能等 Donut 的更新

  • 使用Docker部署包含Prisma的项目

    这几天在使用Nestjs时,使用了Prisma作为ORM

    在开发完成后 一如既往使用Docker部署

    当时的Dockerfile如下「注意这个配置不可行」:

    FROM node:19-alpine3.15
    WORKDIR /app
    
    COPY package.json .
    RUN yarn install --only=production
    
    COPY prisma .
    RUN npx prisma generate
    
    COPY . .
    EXPOSE 3000
    CMD ["node", "dist/main.js"]

    一开始以为是复制文件的顺序问题 调整了十多次都发现报同一个错误:

    当然我在这段Dockerfile中插入了几十条RUN npx prisma generate也没有作用

    翻遍了全网 也有少数人遇到了这个问题 但是都没有很好的解答 因此大概率是我个人的问题

    大致的解决方案都是直接将node_modules文件夹部分或者全部复制进去 就不需要另外初始化了

    虽然不够优雅 但是确实能解决问题


    因为性能考虑 在写Dockerfile 的时候常常先复制package.json到工作目录 然后安装依赖

    在安装完了以后再把所有文件复制到工作目录下

    在项目文件不断迭代中 我们需要重新构建镜像 而Docker在构建镜像的时候 未发生变化的部分是有缓存的

    也就是说提前写好依赖的安装 如果依赖没有变化 Docker就会直接使用之前构建镜像的这一部分结果

    只需要复制变化的逻辑代码

    这样构建会快很多


    当然 铺垫了这么多 最终的解决方案就是:

    复制所有文件 -> 安装依赖 -> npx prisma generate

    Dockerfile如下:

    FROM node:19-alpine3.15
    WORKDIR /app
    
    COPY . .
    
    RUN yarn install --only=production
    RUN npx prisma generate
    
    EXPOSE 3000
    CMD ["node", "dist/main.js"]

    可能说还是顺序问题 也在这个问题上停留足够久了 但是比从开发目录中复制node_modules文件夹优雅一些吧

  • Design Token初探——Figma Tokens插件与Taro React的结合

    Design Token初探——Figma Tokens插件与Taro React的结合

    老早之前就听说了Design Token,最近也在B站学习了草帽老师在新像素账号发表的Design Token的介绍与实战

    作为UI兼开发,经常画完设计稿就懒得写样式了,不同的方式做到同一效果确实十分枯燥

    使用Design Token也能减少一部分重复的工作

    我对Design Token的理解

    在我的理解Design Token就是将样式转换为一个Token,UI在设计时为元素或组件绑定Token后,在框架不变的情况下能够一定程度上不更改代码实现样式的更新与迭代

    Design Token的搭建

    首先是色板,色板的创建方式有很多种,为了方便我通过字节跳动的Figma的Semi插件生成了一个色板

    然后根据草帽老师的讲解,我这里也使用了Figma Tokens插件

    参考草帽老师,我建立了了三层Token:

    (由于是测试可行性,我这里命名也十分草率)

    目前一共是四个文件,后续根据我的工作流,可能还需要建立多个兼容文件,比如说React Native和小程序端支持的样式字段不一样

    为了开发方便,token文件的名称如果没有特殊需求,最好与这个文件的根结点名字相同

    上面的这个token在的根结点为com,它也存在于名为com的Token文件中

    设计稿的绘制

    我也随便绘制了一个设计图

    为了测试切换,我就像上面的三层Token文件一样,制作了sys-dark,用于切换夜间模式

    发现在设计稿中能够正常切换

    结合程序开发

    这样一个Token所生成的就是几个JSON文件,JSON对于开发来说再熟悉不过了,但是此时这个JSON文件转换成样式还需要另外处理

    已知所有的颜色都在这几个JSON文件中,其中也有互相引用的部分

    在某一个标签的颜色的Token中,在不断的寻找上一层的Token后,最终会找到这个颜色

    通过这个Token我就能知道这个Token存在于哪个文件中

    通过递归调用最终能找到这个颜色

    由于我使用的是Taro,所以这里的例子中的夜间模式也是通过Taro的API判断的

    例子代码(不是很会写TS):

    import Taro from "@tarojs/taro";
    
    import base from './token/base.json'
    import sys from './token/sys.json'
    import sys_dark from './token/sys-dark.json'
    import com from './token/com.json'
    
    type Token = {
        value: string | object;
        type: string;
    }
    
    export default class StyleToken {
        systemInfo: any;
    
        constructor() {
            this.systemInfo = Taro.getSystemInfoSync()
        }
    
        private getBatch(key: string): object {
            if (key[0] == '$') {
                key = key.substring(1)
            }
    
            switch (key.split('.')[0]) {
                case 'base':
                    return base;
                case 'sys':
                    if (this.systemInfo.theme === 'dark') {
                        return sys_dark;
                    }
                    return sys;
                case 'com':
                    return com;
                default:
                    return {}
            }
        }
    
        private getToken(token: string): null | string | Token {
            if (token[0] == '$') {
                token = token.substring(1)
            }
    
            let keys = token.split('.');
            let batch = this.getBatch(keys[0]);
    
            let result: any = batch;
    
            for (let i = 0; i < keys.length; i++) {
                result = result[keys[i]];
            }
    
            return result;
        }
    
        public get(token: string): string | object {
            const { value, type } = this.getToken(token) as Token;
    
            if (typeof value === 'string') {
                if (value[0] == '$') {
                    return this.get(value.substring(1))
                } else {
                    return value;
                }
            }
    
            if (typeof value === 'object') {
                Object.keys(value as object).map(e => {
                    (value as object)[e] = this.get(value[e]);
                })
    
                return value!;
            }
    
            return value
        }
    }
    

    在全局状态中实例化上面这个StyleToken类,然后通过这个类的公开方法get获取到某个token的样式

    我这里就直接在某个组件中使用了

    import StyleToken from '@/common/styles/getToken'
    const Style = new StyleToken();
    
    
    // ......省略若干代码......
    
    {
        backgroundColor: Style.get('sys.color.surface'),
    }
    
    // ......省略若干代码......

    最终效果

    我也只实践了顶上的导航栏,为了简便也没有放icon,页面的颜色切换是微信适配的

    最后

    这也是我对Design Token的初步探索,不知道后续会不会有所更新这一部分。

    最后十分感谢草帽老师的分享,让我对Design Token有了认识。

  • SMSForwarder + Scriptable查看安卓备用机电量和短信

    SMSForwarder + Scriptable查看安卓备用机电量和短信

    最近换了个备用机K30 Ultra

    最开始方案

    从一开始我就使用了SMSForwarder

    在之前使用小米6作为备用机的时候是用的go-cqhttp机器人,用来转发短信和电量,具体如下:

    • 短信内容转发到我和机器人所在的群内
    • 群名表示手机电量
    • 群头像表示充电状态

    后来这个QQ号被封了,我就不再使用QQ机器人

    于是我使用了飞书Webhook机器人

    Webhook机器人可以用来转发短信内容

    但是充电状态和电量无法很好的展示,因为群名和群头像不能更改了

    飞书上也没有被动实时展示信息的位置

    解决方案

    最后我使用了Scriptable用来展示电量和最近几条短信

    直接使用小组件在iOS主力机中可以展示安卓备用机的电量和充电状态

    也是简单使用JS代码编写,大致的编写方式和Swift UI类似,效果如下:

    我也是边学边写的,代码写的很粗糙,也有复制别人的部分,然后具体的安卓系统的某些设定我也不是很懂,但是估计改一下API地址就能直接使用,前两种大小的小组件都能使用

    但是好像Scriptable的darkmode的判定有点问题,实际上代码的两种模式适配都是有的,如果Scriptable修复了可以看出来效果

    代码如下:

    const baseUrl = 'SMSFrowarder的API地址'
    
    const request = new Request('')
    
    const defaultHeaders = {
        'Accept': "application/json",
        "Content-Type": "application/json"
    }
    
    const post = async ({ url, body, headers = {} }, callback = () => { }) => {
        body = JSON.stringify(body)
        request.url = url
        request.body = body
        request.method = 'POST'
        request.headers = {
            ...defaultHeaders,
            ...headers
        }
        const data = await request.loadJSON()
        callback(request.response, data)
        return data
    }
    
    const get = async ({ url, headers = {} }, callback = () => { }) => {
        request.url = url
        request.method = 'GET'
        request.headers = {
            ...headers,
            ...defaultHeaders
        }
        const data = await request.loadJSON()
        callback(request.response, data)
        return data
    }
    
    const provideBatteryIcon = (batteryLevel, charging = false) => {
        if (charging) { return SFSymbol.named("battery.100.bolt").image }
    
        const batteryWidth = 87
        const batteryHeight = 41
    
        const draw = new DrawContext()
        draw.opaque = false
        draw.respectScreenScale = true
        draw.size = new Size(batteryWidth, batteryHeight)
    
        draw.drawImageInRect(SFSymbol.named("battery.0").image, new Rect(0, 0, batteryWidth, batteryHeight))
    
        const x = batteryWidth * 0.1525
        const y = batteryHeight * 0.247
        const width = batteryWidth * 0.602
        const height = batteryHeight * 0.505
    
        let level = batteryLevel
        if (level < 0.05) { level = 0.05 }
    
        const current = width * level
        let radius = height / 6.5
        if (current < (radius * 2)) { radius = current / 2 }
    
        const barPath = new Path()
        barPath.addRoundedRect(new Rect(x, y, current, height), radius, radius)
        draw.addPath(barPath)
        draw.setFillColor(Color.dynamic(new Color('#262626'), new Color('#dddddd')))
        draw.fillPath()
        return draw.getImage()
    }
    
    const getBatteryInfo = async () => {
        return await post({
            url: baseUrl + '/battery/query',
            body: {
                "data": {},
                "timestamp": Date.now(),
                "sign": ""
            }
        }).then(res => {
            return res.data
        })
    }
    
    const getSimInfo = async () => {
        return await post({
            url: baseUrl + '/config/query',
            body: {
                "data": {},
                "timestamp": Date.now(),
                "sign": ""
            }
        }).then(res => {
            return res.data
        })
    }
    
    const getSmsInfo = async (num) => {
        return await post({
            url: baseUrl + '/sms/query',
            body: {
                "data": {
                    "type": 1,
                    "page_num": 1,
                    "page_size": num
                },
                "timestamp": Date.now(),
                "sign": ""
            }
        }).then(res => {
            return res.data
        })
    }
    
    
    const createWidget = async () => {
        const widget = new ListWidget();
        let nextRefresh = Date.now() + 1000 * 60 * 5;
        widget.refreshAfterDate = new Date(nextRefresh);
    
        const titleStack = widget.addStack();
    
        widget.addSpacer()
    
        titleStack.layoutHorizontally()
        titleStack.centerAlignContent()
    
        const title = titleStack.addText("额滴圣剑");
        title.font = Font.mediumSystemFont(14)
        title.textColor = Color.blue();
    
        titleStack.addSpacer()
    
        const batteryStack = titleStack.addStack();
        batteryStack.layoutHorizontally()
        batteryStack.centerAlignContent()
    
        const batteryInfo = await getBatteryInfo()
        const batteryLevelVal = batteryInfo.level.split('%')[0]
    
        const isCharging = batteryInfo.status === '充电中' || batteryInfo.plugged === 'AC'
    
        const batteryText = batteryStack.addText(`${batteryLevelVal}%`)
    
        batteryText.font = Font.mediumSystemFont(12)
    
        if (isCharging) {
            batteryText.textColor = Color.green()
        } else if (batteryLevelVal < 20) {
            batteryText.textColor = Color.red()
        }
    
        batteryStack.addSpacer(2)
    
        const batteryIcon = batteryStack.addImage(provideBatteryIcon(batteryLevelVal / 100, isCharging))
        batteryIcon.imageSize = new Size(30, 30)
    
        const simInfo = await getSimInfo()
        const smsList = await getSmsInfo(3)
    
    
        for (let n of smsList) {
            const smsItemStack = widget.addStack();
            smsItemStack.layoutHorizontally()
            smsItemStack.centerAlignContent()
    
            const title = smsItemStack.addText('发给' + simInfo[`extra_sim${n['sim_id'] + 1}`])
            title.font = Font.mediumSystemFont(11)
            title.textColor = Color.gray()
    
            smsItemStack.addSpacer()
    
            const smsItemDateVal = new DateFormatter()
            smsItemDateVal.dateFormat = 'yyyy-MM-dd HH:mm:ss'
    
            const smsItemDate = smsItemStack.addText(smsItemDateVal.string(new Date(n['date'])))
            smsItemDate.font = Font.systemFont(10)
            smsItemDate.textColor = Color.gray()
    
            const sms = widget.addText(n.content)
    
            sms.font = Font.mediumSystemFont(12)
            sms.lineLimit = 2
            widget.addSpacer()
        }
    
        widget.backgroundColor = Color.dynamic(new Color('#eeeeee'), new Color('#242424'))
    
        return widget
    }
    
    const widget = await createWidget()
    
    if (config.runsInWidget) {
        Script.setWidget(widget)
    
    } else widget.presentMedium()
    
    Script.complete()

    引用和参考(fuzhi和zhantie)

    https://github.com/mzeryck/Weather-Cal/blob/main/weather-cal-code.js

    https://github.com/evilbutcher/Scriptables/blob/master/Env.js

  • 小程序云开发数据库/Mongodb时间戳转时间

    我个人习惯在数据库中把所有时间按照时间戳保存,因此在某些使用场景如查询同一天过生日的人,需要忽略年份

    此时需要将时间戳转为时间,并且格式化查询

    AggregateCommand.add(value: Expression[]): Object
    聚合操作符。将数字相加或将数字加在日期上。如果数组中的其中一个值是日期,那么其他值将被视为毫秒数加在该日期上。

    故如下操作即可

    $dateToString: {
        format: "%m%d",
        date: {
            "$add": [new Date(0), "$birthday"]
        }
    }
  • Javascript使用Selenium

    因为一些特殊原因,有一些网页用常规爬虫手段特别难爬,就常用到Selenium,由于我是做微信小程序开发的比较多,所以尽可能都是用Javascript

    恰好Selenium提供了Javascript的使用文档,而简单易懂的使用案例局限性比较大,网上的帖子大多都是Python的

    远程调用

    部署远程服务器

    在服务器上使用下面的命令启动容器,这里使用的是Chrome,如果要使用FireFox,Safari,Edge等其他浏览器去GitHub上找就行了这里是链接

    $ docker run -d -p 4444:4444 --shm-size=2g selenium/standalone-chrome

    Javascript使用

    首先需要安装selenium-webdriver,使用npm install selenium-webdriver即可

    const {
      Builder,
      Capabilities,
      By,
      until
    } = require('selenium-webdriver');
    
    const capabilities = Capabilities.chrome();
    
    // ------
    // 如果要忽略SSL问题加上这一行
    // capabilities.setAcceptInsecureCerts(true);
    // ------
    
    const driver = new Builder()
        .usingServer("http://××.××.××.××:4444") // 服务器地址
        .withCapabilities(capabilities)
        .build();
    
    await driver.get('目标网址');
    
    // 等待指定元素加载完成 / 出现
    await driver.wait(until.elementLocated(By.id('id')), 10000);
    
    // 查找指定元素
    const input = driver.findElement(By.className('input'));
    
    // 往指定元素输入内容
    await input.sendKeys('text');
    
    // 点击某个元素
    driver.findElement(By.className('submit')).click();
    
    // 获得指定名称的Cookie
    await driver.manage().getCookie('Cookie');
    
    // 关闭
    await driver.quit();

    大概我用到的就是上面所写的,具体更加详细的文档点这里,上面只是列举一下常用的代码,这个文档翻着挺麻烦的,总结常用的会节省一些时间。

  • 小程序长列表性能优化

    小程序长列表性能优化

    2023 年 1 月 26 日更新:

    在 skyline 渲染器中, scroll-view 设置 type=”list” 即可自动处理长列表的渲染


    在小程序官方的文档“性能“一部分中写道

    建议一个页面使用少于 1000 个 WXML 节点,节点树深度少于 30 层,子节点数不大于 60 个。一个太大的 WXML 节点树会增加内存的使用,样式重排时间也会更长,影响体验。

    但是有一些场景下我们需要”无限“下划,此时越往下划页面上的WXML节点就越多。于是不显示在屏幕上的节点我们可以将其卸载掉。

    在微信官方文档中,平台能力-扩展能力-扩展插件-“recycle-view”组件就可以用来解决这个问题,但是我们需要自己写一个方法来传入当前循环的列表组件的尺寸,但是很多时候,我们不能很好的预知即将渲染的组件的确切尺寸。

    解决方法

    我们在微信官方文档中看到了IntersectionObserver,可以用来监听组件是否和某个区域有交集,我们使用IntersectionObserver IntersectionObserver.relativeToViewport(Object margins)来监控可视区域的所有组件

    同时长列表中的每个元素大多为循环,给每一个组件分配一个唯一的id,然后监听这个组件即可:

    this.listener = this.createIntersectionObserver()
            this.listener.relativeToViewport({
                top,
                bottom
              }).observe(`#${id}`, (res) => {
                if (res.intersectionRatio == 0) {
                  this.setData({
                    show: false,
                    height: res.boundingClientRect.height
                  })
                } else {
                  this.setData({
                    show: true,
                    height: res.boundingClientRect.height
                  })
                }
              })

    参考recycle-view中在滚动过程中,为了避免频繁出现白屏,会多渲染当前屏幕的前后2个屏幕的内容,所以上面的top和bottom参数为两个屏幕高度。

    然后在渲染和卸载时,重新获得组件高度,在下次渲染时基本能够确定高度,设置css min-height为监听器中获得的height,在内容卸载后保持原有高度,防止滚动条发生奇怪的抖动。

    由于近期我在外面出差,所以只是大致在原理上讲了讲,在即将写完的时候,发现网上有一篇文章使用了类似的方法,并且还给出了相关示例代码,文章:https://juejin.cn/post/6844904014404927496,GitHub:https://github.com/godaangel/wxapp-long-list

    大家可以参考和使用他的解决方法,他的示例代码中没有卸载时的组件高度获取,时间关系,我就不测试是否会出问题了,我认为如果在中途组件高度有变化,在卸载时重新获取一次组件高度似乎更加可行。