普通视图

发现新文章,点击刷新页面。
今天 — 2024年5月19日首页

又是疯狂的一周,全世界都「AI 麻了」!

2024年5月19日 13:50

是因为「劳动节」吗?全世界所有的公司,都选择在 5 月第三周,将最新的 AI 产品和技术,集中释放。

丧心病狂的一周!

铺垫了许久的「周一见」,OpenAI 用 GPT-4o 夺走了注意力。24 小时后的发布会上,谷歌也没有「掉链子」,Veo 视频模型、Project Astra、新版 AI 搜索都留下了惊艳的记忆点。

地表最强但风格迥异的两场发布会,只在一点上达成共识——电影《Her》一般的超强语音助手(GPT-4o 和 Project Astra),这也变相公布了 2024 年大模型赛道的竞赛点——GPT-4o 和 Astra 背后的多模态融合技术。

大洋彼岸的另一端,姗姗来迟的字节跳动发布了豆包大模型家族,腾讯终于交出了「GPTs」和大模型助手 App 的答卷。

今天看来,无论是「拖家带口」的大厂,还是「没有包袱」的创业公司,产品形态都一再扩展:从聊天机器人,到 AI 搜索、「GPTs」、多模态语音助手.... 玩得越来越花。

不知道你麻没麻,反正我们是幸福地麻了。

5 月 13 日(周一)

AI 化身/人形智能体正在快速演进:宇树发布 Unitree G1 人形机器人

¥9.9 万元起,远低于行业售价

大语言模型出圈,让可以实现具身智能的人形机器人火了。

2023 年 8 月,宇树科技发布了人形机器人 H1,预售价为 9 万美元(约合 65 万元人民币)。本周,宇树推出的新版人形机器人 Unitree G1 将价格降到 9.9 万元人民币起,便宜了超 80%。

与第一代产品相比,Unitree G1 能力显著提升:开瓶盖、砸核桃、颠锅、跑步、舞棍、自我蜷缩……在宇树科技发布的产品演示视频里,身体和双腿能旋转近 360°,Unitree G1 像人类一样用机械双臂灵活地完成一系列工作。

图片来源:宇树科技

开源闭源并进:零一万物发布千亿参数 Yi-Large 模型

开源构建生态,闭源探索 AI 上限

零一万物成立一周年之际,其千亿参数 Yi-Large 闭源模型正式亮相,在斯坦福最新的 AlpacaEval 2.0 达到全球大模型 Win Rate 第一。

同时,零一万物将早先发布的 Yi-34B、Yi-9B/6B 中小尺寸开源模型版本升级为 Yi-1.5 系列,每个版本达到同尺寸中 SOTA 性能最佳。

Yi 大模型 API 开放平台 | 图片来源:零一万物

5 月 14 日(周二)

「Her」真的来了:「GPT-4o」将语音助手带到了新高度

多模态融合模型,只是工程的进步吗?

OpenAI 发布了新一代旗舰模型 GPT-4o,它可以让人们在手机上与 ChatGPT 对话,就像他们与 Siri 和其他语音助手对话一样。不同的是,ChatGPT 语音助手的理解能力有了质的飞跃,还可以分析和讨论它所看到的图像或视频,并能识别用户说话时的不同情绪。

有了 GPT-4o 的加持,ChatGPT 可以根据你的想法引导你做数学题目、按照你的实时要求讲一个睡前故事。OpenAI 称 GPT-4o 是为了创建一个对音频、图像和文本有更深入、更自然理解的模型,这依旧是为了向 AGI 目标行进。

OpenAI 的发布,也在 AI 圈引起了广泛讨论。业界普遍认为,GPT-4o 的惊艳之处在于两点:1)将语音交互延迟缩短到 300ms;2)端到端多模态原生大模型

P.S.: 留一个观察作业:GPT-4o 会显著提升 ChatGPT 的日活和用户粘性吗?有了更高 AI 能力的智能助手,2016 年的百箱大战会席卷重来?Siri 一样的语音助手会成为入口级的必争之地?

图片来源:OpenAI

5 月 15 日(周三)

没有一款产品没有被 AI 改造:谷歌全面进入 Gemini 时代

Sora 尚且是科技巨头的选做题,但多模态融合就是大模型公司的必做题。

提了 121 次 AI,谷歌 I/O 2024 开发者大会发布了一箩筐,从搜索到 Gmail、TPU,再到语音助手 Astra 和多模态视频模型 Veo 等。

三个产品值得关注:

  • Project Astra 的多模态 AI 助手。如果说 2023 年的竞赛点是 Copilot,2024 年,赛点则进化为多模态融合的 Agent,背后是从 LLM(大语言模型)到 One-network-multimodality(一个框架下的多模态大模型)的技术路径迁移,最终迈向跟通用的人工智能。

多模态语音助手正在与用户实时对话|图片来源:Google

  • Veo:Veo 可以根据文本、图像和视频提示创建 AI 生成的视频,并且即将登陆 YouTube,帮助创作者快速制作更专业品质的视频。
  • AI 搜索:谷歌展示了如何进一步将人工智能集成到搜索中,从而实现更复杂形式的研究和规划(例如,根据查询生成三天的素食计划)。

图片来源:谷歌黑板报

大模型之字节打法:没准备好就不发,否则一次发布 9 个模型

模型发得晚,应用没少做,怎么想的?

字节跳动自研大模型豆包大模型(原云雀大模型)家族带着 9 个模型,首次对外亮相。字节跳动方面称,之所以是这 9 个模型,是根据后台模型调用量和需求而来,做了最强通用模型、性价比之选、和场景优化模型。

豆包大模型的推理价格成为一大亮点,其主力模型在企业市场的定价只有 0.0008 元/千 Tokens,0.8 厘就能处理 1500 多个汉字。

值得注意的是,字节发布会没有介绍模型参数、数据和语料,甚至没有给出豆包模型的评测数据,而是直接把模型能力在场景里做了垂直细分。字节可能是在建立用户反馈、数据反馈,从而做更精准地场景和服务。根据不同的数据链反馈,决定产品或者模型的下一步动作。

过去大半年,字节跳动推出的 AI 应用几乎涵盖了所有热门赛道,「豆包」、AI 应用开发平台「扣子」、互动娱乐应用「猫箱」,以及星绘、即梦等。

图片来源:字节跳动

大模型队伍的隐秘玩家:DeepSeek Chat 通过大模型备案

降成本!我带头!

国内拥有超过 1 万枚 GPU 的企业不超过 5 家,幻方这家千亿规模的量化基金就是其中之一。意外地提前压中大模型的入场券——囤卡,但幻方做大模型是认真的。

今年 1 月以来,幻方旗下公司的 DeepSeek 模型被频繁作为开源社区里讨论的对标对象。本月,幻方开源了第二代 MoE 模型:DeepSeek-V2,主打参数更多、能力更强、成本更低。其在能力逼近第一梯队闭源模型的前提下,推理成本降到了 1 块钱 per million token,也就是说,成本是 Llama3 70B 的七分之一,GPT-4 Turbo 的七十分之一。而且,DeepSeek v2 还有利润。

DeepSeek v2 发布后,引来了大模型价格战,智谱、面壁、字节相继宣布了模型推理价格降低。这背后是模型架构、系统、工程的一系列进步。你有没有发现,OpenAI 的价格也降低了 10 倍不止。

Anyway,现在,DeepSeek-V2 已经通过备案,你可以联网体验,隐秘玩家的隐秘实力究竟如何?

图片来源:DeepSeek

5 月 16 日(周四)

文生图、文生视频:DiT 架构正在被广泛拥抱

开源力量大

腾讯旗下的混元文生图大模型宣布对外开源,目前已在 Hugging Face 平台及 Github 上发布,包含模型权重、推理代码、模型算法等完整模型,可供企业与个人开发者免费商用。

混元文生图大模型是中文原生的 DiT(Diffusion Models with transformer)架构文生图开源模型,这也是 Sora 和 Stable Diffusion 3 的同款架构和关键技术,是一种基于 Transformer 架构的扩散模型。过去,视觉生成扩散模型主要基于 U-Net 架构,但随着参数量的提升,基于 Transformer 架构的扩散模型展现出了更好的扩展性,有助于进一步提升模型的生成质量及效率。

5 月 17 日(周五)

「GPTs」和大模型助手 App:大厂必备,腾讯版来了

已接入 600 多个腾讯内部业务和场景

本周,腾讯公布了大模型研发、应用产品的系列进展。

腾讯混元大模型升级,推出在质量和成本上有不同特点的三个模型版本,其内部已经有 600 多个业务接入大模型。

在工具层,发布了腾讯云大模型知识引擎、图像创作引擎、视频创作引擎三大 PaaS 工具链,简化数据接入、模型精调、应用开发流程。

值得注意的是,腾讯终于推出了自家「GPTs」——元器,用户可以使用腾讯官方的插件和知识库直接创建智能体。开发完成后,将智能体一键分发到 QQ、微信客服、腾讯云等渠道上。腾讯还将于月底推出基于混元大模型的全新助手 App「腾讯元宝」。

腾讯元器官网开放申请试用

写在最后:

本周,与上述 AI 产品、技术发布一同进展的,还有各大 AI 公司的「水下操作」。

什么都无法阻挡 Scaling Law 的脚步:

  • 主导超级对齐的 OpenAI 联合创始人兼首席科学家 Ilya Sutskever 在社交平台 X 上宣布,他将离开公司。随后,超级对齐团队负责人之一 Jan Leike 也宣布离职,并发推称,超级对齐团队在公司内部被边缘化,无法获取计算资源做研究。
  • AWS CEO Adam Selipsky 离职,或由于 AWS 错失 AI 投资和研发的最佳时机。
  • 微软宣布将在法国投资 40 亿欧元,大部分将集中在 AI 领域
  • 马斯克的 xAI 斥资近 100 亿美元租用 Oracle 人工智能服务器

AI 应用正在拓展既有想象力:

  • 企业级可用大模型的 Anthropic 从 Instagram 挖来了 CTO 做产品,或进军 ToC APP。
  • Meta Platforms 正在开发的带有摄像头的人工智能耳机项目,摄像头将使耳机能够识别佩戴者周围物理世界中的物体。Sam Altman 最近也被曝和前苹果设计大师 Jony Ive 正在探索开发带有摄像头的 AI 耳机,「很快你的耳朵里也会长出眼睛」。

微软 Build 大会官网|图片来源:微软

下周,北京时间 5 月 22 日凌晨,AI 的另一大玩家微软,即将在西雅图举办 Hybrid:Microsoft Build 大会。官方网页上大大的「How will AI shape your future?」,强调了本次大会的主题。

金钱永不眠,AI 也是。

id选择器出现特殊字符

2024年5月19日 13:06

在接入外部友商的sdk,友商使用了一些特殊字符串作为选择器,导致获取该元素失败。针对这种情况列举下一些处理方案

原因:

CSS 中,标识符是用于命名选择器、类、ID、动画名称、变量名等的名称。CSS 标识符必须遵循一定的语法规则,以确保它们能够被正确解析和使用。以下是 CSS 标识符的规则:

  • 字符集:标识符可以包含字母(A-Z 和 a-z)、数字(0-9)、连字符(-)、下划线(_)和 Unicode 字符。
  • 开头字符:标识符不能以数字、连字符、两个连字符开头。
  • 特殊字符:!、@、#、$、%、^、&、*、(、)、[、]、{、}、|、\、:、;、'、"、<、>、,、.、?、/ 和空格

解决方案:

设置id比较特殊的选择器

<div id="a/b/c#_d"></div>
  • 1、直接使用document.getElementById(id)
    const id="a/b/c#_d";
    const ele= document.getElementById(id);
    console.log("ele", ele);
  • 2、querySelector+ 设置转义
    const id = "a/b/c#_d".replace(/([ !"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~])/g, '\\$1');
    const ele = document.querySelector(`#${id}`);
    console.log("ele", ele);
  • 3、(取巧)querySelector+使用属性id选择器
const id="a/b/c#_d";
const ele = document.querySelector(`[id="${id}"]`);
console.log("ele", ele);

getElementByIdquerySelector差异:

  • getElementById 是 DOM 的一种方法,专门用于通过 id 属性来获取元素。因为 id 在 HTML 中是唯一的字符串标识符,不涉及到 CSS 选择器的语法,所以不需要转义
  • querySelector 是基于 CSS 选择器语法的通用选择方法。由于 CSS 选择器中,特殊字符有特定含义(例如 # 用于选择 id,. 用于选择 class 等),所以这些字符需要进行转义以确保选择器能够正确解析和匹配

参考

  • https://drafts.csswg.org/selectors/#case-sensitive -https://developer.mozilla.org/zh-CN/docs/Web/API/Document/querySelector

在复刻黏土风图生成中学习 — 模型微调/LoRA 原理/图生图

作者 bang
2024年5月19日 11:07

继续学习 Stable Diffusion,这次想以搭建一个实际可用的生图场景 — 黏土风格作为引导,弄清楚整个流程的同时,把过程中遇到的相关概念和原理也做了解,所以这篇是掺和了应用流程和原理的文章。

ComfyUI & 模型

使用 Stable Diffusion 去生成图,有非常多的插件/模型/配置相互搭配组合使用,一般用 WebUIComfyUI 这两个工具,更推荐 ComfyUI,自由串联一个个模块,流程更清楚,网上有很多在自己电脑部署使用 comfyUI 的保姆级教程,比如这个,这里就不多介绍了。

先看 ComfyUI 这个默认的最简单的 workflow:

1

这里面简单的几个元素概念和生图流程,上篇文章都有介绍过:最左边的 Load Checkpoint 是加载 SD 模型,接着用 CLIP 模型编码文本 → 生成隐空间原始噪声图 → 采样器根据文本和噪声图输入→在隐空间里迭代降噪生成隐空间图片→最终用VAE解码图片。

为什么叫模型 checkpoint ?模型在微调训练过程中,会在关键节点保存模型参数的状态,这个保存点被称为 checkpoint,SD 有大量基于基座模型微调训练的模型,包括官方出的,比如 SDv1.5 是从 v1.2 的基础上调整得到的,SDXL Turbo 也是基于 SDXL1.0 基础上训练的,这些模型都被称为 checkpoint,这些 checkpoint 包含了生成图所需要的全部核心组件,包括 VAE、CLIP、UNet 的模型数据,可以直接使用。

那模型文件的后缀为什么是 .safetensors ?早期的模型文件后缀是 .ckpt (checkpoint缩写),一个通过 Python 序列化后的数据,使用时需要对它反序列化,这个反序列化过程也就容易被注入恶意代码,所以后面提出了新型安全的格式 safetensors,只包含张量数据(模型上的参数数据),无需反序列化,安全且速度快,目前模型基本都以这种方式存储。

我们用这个默认 workflow,选个模型,用纯提示词 claymation style, a tower 试试生成黏土风图片:(图上使用了 dreamshaperXL 模型,是在SDXL 的基础上微调的最受欢迎的一个模型)

2

可以看到效果并不是很好,比较生硬。可能加多一些细节提示词、调节下相关参数会好一些,但在图片训练过程中,黏土风格相关的图片数量应该是不多的,训练图片对应的文本描述也比较散,如果固定要这种风格,生图的 prompt 要尽量贴近训练时这类图偏对应的文本,才有可能有好一点的效果,这很难控制,也不保证效果,很难达到我们想要的风格。

模型微调

如果我要一个能更好输出黏土风格的模型,那可以给这个模型做微调,给它输入更多黏土风格的图片训练,让它学会我们具体要的是什么,针对性输出。

微调 SD 模型,目前从成本大到小,目前用得最多的有三种方式:

  1. Full Finetune:
    1. 最朴素的方式,使用图片+ 标注的数据集,进行迭代训练,对原模型所有参数进行调整,成本最高,但可以对整个模型做全面调优,大幅改变生成风格,上面的 dreamshaperXL 就是以这种方式。它训练数据量要求大、计算资源消耗高、最终模型就是包含所有模型参数的 checkpoint。
    2. 这种训练我理解适合大量的数据、对模型整体做调优较合适,如果只是想在特定领域,用少量数据,比如把某只猫,把某个人脸、某个物品训练进去让模型认识,那很可能出现过拟合问题(数据不够多样,污染了通用词,比如拿自家的猫训练,最终整个模型对 cat 这个输入只能生成自家的猫),或欠拟合问题(训练样本太少,没有影响到网络参数,训练无效)。
  2. Dreambooth:
    1. 针对 Full Finetune 过拟合和欠拟合、数据量大的问题的一种解决方案,数据量要求小,个位数的图片可训练,能很好还原训练图片里的人物/物品,同时不会污染原模型,能正常用原模型的能力,只在有特殊 prompt 的情况下命中微调的效果。它跟 finetune 一样是修改整个模型的参数,所以产物跟原模型一样大,也是个完整的 checkpoint。具体原理可以看这些文章(1 2)的介绍。
  3. LoRA:训练门槛极低,只需要个人PC的算力、个位数(三五张图片)也能训练出特定人物、风格的微调模型,也能达到很好的效果,生成的是外挂文件,体积小可插拔,是目前 SD 使用最广的微调模型,原理下面细讲。

像黏土风格这种诉求,仅是一种风格化的优化,是比较适合使用 LoRA 模型的,从 civitai (最大的SD 模型社区)上找了个黏土风的 LoRA 模型 CLAYMATE,在原 workflow 简单加上这个 LoRA模型,先看看应用的效果:

3

同样的提示词下,效果好了很多,是比较舒服的黏土的风格。

每个 LoRA 都有个触发词,上面用的这个模型触发词就是 claymation style,我理解相当于训练时大部分图片的 prompt 标签都加上了这个词,这样使用这个词时,模型能更好定位到训练到的数据。比如下面去掉这个claymation style ,即使用了 LoRA,也对应不上这个 LoRA 的风格。

4

这模型是怎么训练的?基本上流程是,选好图片→处理图片(裁剪+加提示词)→用工具 Kohya 训练→看结果调参重复,具体跟着网上教程走就行,各种参数细节参考这里,我还没真正执行过训练,先不多说。另外训练的整个代码生态都是围绕 NVIDIA 显卡建立的,Mac 没有 NVIDIA 显卡,没法训练。虽然理论上可行,但社区生态不友好,基本不可用,要训练只能搞台 PC 或用云服务器。

LoRA 原理

来具体看看 LoRA 的原理,全称 Low-Rank Adaptation,低秩适应。什么是低秩?为什么能做到微调成本低文件小效果也不差?

Full finetune

先看看正常的微调,也就是前面说的 Full finetune,下面这图很好理解,正常微调就是通过新增的训练集,重新调整这模型里面网络的参数,把这个参数更新到原有网络里,变成一个新的模型使用。

5

这里除了前面说的要求的训练数据量大、容易过拟合/欠拟合的问题外,还有个大的问题,就是计算量大、资源要求高。

大模型都是由多层神经网络叠加组成,Transformer 和 UNet 都是,使用这些模型时,是对这些模型正向推理,这个过程需要的资源不高,只需要把模型参数全部加载进内存,一层层正向计算就行。

6

但训练这些神经网络要求就比正向推理高很多,整个训练中,每一步训练的过程包括:

  1. 前向传播:训练数据(样本)输入当前网络,生成预测结果(跟使用模型一致)
  2. 损失函数计算:把预测结果跟样本对应的预期输出对比,评估差异
  3. 反向传播:把这个差异(损失函数的梯度),反向从输出层到输入层传播,计算每一层每个参数对这个差异的贡献,记下相应数据。
  4. 更新参数:对反向传播获得的数据,更新网络中每个参数值

这里内存中就需要同时存在好几个数据:1.原网络参数 2.前向传播过程中计算出来的数据,反向传播计算时需要用到 3.反向传播过程中每个参数的差异贡献(梯度值),更新参数时要用到。

所以假如整个神经网络有 n 个参数,每一步训练就要在显存存储 3n 个数据,进行 3n 次计算(正向推理、反向传播计算,更新每个参数的值)。还有一些训练优化的方法,数据的存储和计算量会更高。Stable diffusion XL 的参数量是35亿,llama 3最高参数量达到4000亿,每一步的计算量感人。

参数冻结

再来看看 LoRA 怎么解这个问题。

首先,LoRA训练过程中,会把原网络参数冻结(下图的W),不会去修改原网络参数,只让原网络参与正向推理预测结果的过程,所有对参数的调节都独立出来在 △W 上,这个△W最终也不会更新在网络上,只会在使用这个模型时外挂式地加上它,跟原模型一起叠加共同进行推理。

到这里,其实它只是换种方式,专门把训练变化的部分抽出来,其他都没变,△W 跟 W 的参数个数一样,该存储和计算的量一样。

7

低秩分解

下一步才是主要的,接下来需要一点点基础线性代数矩阵的知识。

前面这个分离出来的部分△W,变成下图这样,不是用跟原网络一样的参数量去表示,而是通过一个数学的方法 低秩分解 去表示,把原模型参数 W 和 △W 看成一个矩阵数据,那 △W 这个矩阵可以用两个小矩阵Wa 和 Wb 相乘去表示,这过程就叫低秩分解。

8

矩阵中秩(Rank)的概念简单说就是矩阵中行列较小的那个值,比如 100 x 5 的矩阵,秩就是5,把100 x 100 的矩阵,分解成 100 x 5 和 5 x 100 的矩阵相乘(相乘后是100×100的矩阵),就是低秩分解。图中的 r (rank)就是这个秩。

可以看到这个秩越小,数据量越小,比如分解成 100×10 和 10×100 两个矩阵,这俩矩阵数据量是2000,如果分解成 100 x 5 和 5 x 100 ,数据量是1000,相对于原矩阵 100×100 数据量 10000,要存储的数据量下降10倍。

所以 LoRA 训练出来的不是△W,而是分解后的两个低秩矩阵 Wa 和 Wb,这就是 Low-Rank Adaptation 低秩适应的意思。设定的秩的值越低,所要存储的数据量越低, 所以 LoRA 模型会根据训练时设置的秩值,比原模型大小低一两个数量级。同时训练过程中,因为这俩低秩矩阵跟原矩阵不是一个数量级,所需要的计算量和显存也相应减少了,在普通 PC 也能跑起来。

看起来很神奇,把一个完整的数据做这样的分解,数据的信息量肯定减少了,为什么用这种方式做微调,效果还能好?LoRA 论文中表示,在预训练的大模型上微调时,如果是处理一个细分的小任务,参数的更新主要在低维子空间中,很多高维子空间的参数在微调前后根本就没动,越简单的细分任务,对应要更新的参数维度就越低,可以简单理解为 LoRA 的低秩分解能对应到大模型低维子空间的参数更新中,更多扩展阅读参考 1 2 3

模型微调和 LoRA 原理就介绍到这里,我们继续回到黏土风的整个 workflow。

图生图

黏土风格这种模型的应用场景是给用户当图片特效使用,也就是需要图生图的方式,前面只做了文生图,来看看图生图的 workflow,以及生成的效果:

9

只需要在前面的 workflow 基础上,把生成空白噪声图 Empty Latent image 节点,改成 Load image + VAE 编码节点即可。

这里的原理很好理解,文生图是用一张全是随机噪点的图作为输入,沿着 Prompt 逐步降噪生成图片。我们把这个输入换成一张真实的图片,再在上面加上一定量的随机噪点,代替完全随机噪点图进行采样迭代逐步生成图片。把这个加了噪点图片看成是图片生成降噪过程中的某一步状态的话,相当于之前图片是从 0 开始生成,现在是从某一个中间过程点开始去生成,整个流程是一样的。

这里要加多少噪点,在 KSample 这个采样器节点的 denoise 里可以设置,来看看同样的文本 prompt 和图片输入的情况下,设置不同值的效果

10

denoise 为 0 时,没有加噪点,也就没有去噪的过程,输出是原图,denoise 为 0.4 时,可以猜想到,这时加的噪点程度不影响图的主结构,猫戴帽子的细节也还有,那降噪生成后这些细节还会保留,随着噪点越来越多,原图的细节越来越少,到 denoise 为1.0时,也就是加100%噪点时,跟输入一张随机噪声图是一样的,跟原图就没什么关系了,只跟输入的 Prompt 文字有关系。

看起来设置 0.4 到 0.6 之间的降噪,效果还可以,即保留了原图整图的大致内容,也能适当加上黏土的风格。用其他图片尝试,也还行:

11

图片 Tag 生成

但这里不同的图片的 Prompt 得自己手动改,才能生成类似的图,我们正常使用这类风格化滤镜是不需要自己输入 prompt 的。我们可以加个图片 Prompt 生成器,让它描述输入的图片,再作为图片生成的过程。

我们添加 WD14 Tagger 这个插件节点,它可以从图片中反推出适用于 SD 提示词的标签(booru风格标签),我们把它解析出来的 tag,跟 LoRA 模型的触发词 claymation style page 一起组合,作为 Prompt 输入,这样就实现了只输入图片,不用修改 Prompt 就能产出对应的图了。

这里注意得用 (claymation style page:2) 这种 Prompt 权重表达方式,把这几个 LoRA 模型的触发词调高权重,不然会淹没在图片输出的众多Tag上,没法起到作用。

12

13

到这里,我们实现了一个最简单的黏土风格图生图流程,也认识了过程中涉及到的技术原理。目前这只是个最简单的 demo,在某些图片下效果还可以,但要真正达到可用还有很多问题,后续会随着一些问题继续探索 SD 里的相关技术。

参考资料

LoRA模型微调原理:https://www.bilibili.com/video/BV1Tu4y1R7H5

利用LoRA对LLM进行参数高效的微调:https://zhuanlan.zhihu.com/p/632159261

神经网络训练:https://blog.csdn.net/brytlevson/article/details/131660289

Stable Diffusion原理可视化:https://www.youtube.com/watch?v=ezgKJhi0Czc

使用 Turborepo 管理的 Monorepo 项目跨项目时如何共享代码

作者 OXXD
2024年5月19日 11:01

上篇文章中介绍了 Turborepo 管理 Monorepo 项目中可以通过内部库(Internal Packages) 的方式共享代码。

如果在同一个 Monorepo 项目中使用的,那么内部库就足够了,但是如果遇到多个项目同时需要使用公共的代码,那么就会需要考虑外部库(External Packages),相较于内部库而言,外部库会经过打包发布版本推送到一个集中的 npm 仓库提供给不同的项目使用,在跨项目使用的场景下使用,比如团队内基础组件库。

本文介绍使用 Turborepo 管理的 Monorepo 项目跨项目时如何共享代码,主要介绍外部库的打包和发布版本方式。

打包项目

发布到 npm 仓库的包都需要提前打包,使用者不需要直接引用源码,不需要知道实现细节可以直接使用,也避免了出现不同项目打包工具和配置不一样导致的打包问题。目前主流的打包工具都支持打包出 ECMAScript modules (esm)CommonJS modules (cjs),主流的项目脚手架和打包工具也都支持引入和打包这两种格式的第三方包。

目前主流的打包工具有 Webpack, Vite, Rollup 等,有各自的优劣势,本文将介绍使用 tsup 打包项目,是 Turborepo 文档 Bundling packages in a Monorepo – Turborepo 中推荐的,支持 .js, .json, .mjs, .ts, .tsx 格式的文件的无配置打包,在小项目中上手使用十分方便快速。

以下目录结构为例,我们创建了一个库 math-helpers,并希望将他打包后发布

├── apps
│   └── web
│       └── package.json
├── packages
│   └── math-helpers
│       ├── src
│       │   └── index.ts
│       ├── tsconfig.json
│       └── package.json
├── package.json
└── turbo.json

安装 tsup

npm i tsup -D

定义打包命令

/packages/math-helpers/package.json 中定义打包命令,直接使用 tsup 打包 src/index.ts 的入口文件,并且导出 cjs,esm 格式和自动生成的类型定义

{
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts"
  }
}

将打包后的产物 dist 文件配置一下

  1. .gitignore 中忽略 dist,这部分不需要提交到 Git 项目
  2. 将打包后的产物地址 dist,添加到 turbo.json 中的 pipelineTurborepo 可以帮我们缓存打包结果,加快下次打包
{
  "pipeline": {
    "build": {
      "outputs": ["dist/**"]
    }
  }
}
  1. package.json 中定义文件入口
{
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts"
}

更多 tsup 的使用方式可以参考 文档,或者参考使用其他打包工具,如 Vite

发布项目

项目打包完成后,需要考虑如何发布版本到 npm 仓库。其中包含这几件事:

  1. 版本管理,Semantic Versioning 还是 Calendar Versioning
  2. 发布
  3. npm 仓库,私有还是公开

在前端社区也已经有不少最佳实践和好用的工具来帮助我们完成发布项目这一流程。本文将介绍的是 Turborepo 文档 Versioning and Publishing Packages in a Monorepo – Turborepo 中推荐的 changesets 这一工具和其推荐的发布流程。

安装并初始化

npm install @changesets/cli && npx changeset init

使用

初始化完成后,使用方式和流程基本就是以下三个命令。下面介绍下每个命令的作用

# Add a new changeset
changeset
 
# Create new versions of packages
changeset version
 
# Publish all changed packages to npm
changeset publish

添加更新说明

changeset

会生成一次更新说明,并且保存文件在项目中,可以执行多次,会生成多次更新说明,后续会根据这些更新说明来生成 CHANGELOG

当修改完成后,需要准备发布版本时

changeset version

会提供可交互的界面,选择需要发布的版本,按照 semver 规范,选择 patch, 'minor', 'major' 版本

这部分命令执行完后,会生成 CHANGELOG,和会自动更新 package.json 中的版本为正确的版本(需要提交到 Git),下一步即可发布到 npm 仓库

发布到 npm 仓库

changeset publish

这个命令代替了 npm publish 这个命令,会发布到 npm 仓库。(注意记得发布之前需要打包!!!)

发布完成后,其他项目既可以通过 npm install 的方式安装使用。在公司内部项目,一般都会使用内部 npm 仓库,如何发布到内部 npm 仓库是另一个话题,会在之后介绍。

参考链接

Vue Router源码分析(二)-- createMatcher

作者 ChrisLey
2024年5月19日 10:38

Vue Router源码分析(二)-- createMatcher

果然还是很在意这个matcher,这个显眼包在上一篇阅读createRouter时,时不时在我眼前晃一下。算了咯,再不乐意看也得看看咯。

1. RouterMatcher

照例先看类型接口,这样才晓得这家伙有几斤几两。

先是增删查与解析呗,Router几个方法addRouteremoveRoutegetRoutesresolve都是会调用RouterMatcher对应的方法。喔哦,这还有个getRecordMatcher,看签名是通过路由名称来查找matcher,还挺会玩儿。

export interface RouterMatcher {
  addRoute: (record: RouteRecordRaw, parent?: RouteRecordMatcher) => () => void
  removeRoute: {
    (matcher: RouteRecordMatcher): void
    (name: RouteRecordName): void
  }
  getRoutes: () => RouteRecordMatcher[]
  // 通过路由名称来查找`matcher`
  getRecordMatcher: (name: RouteRecordName) => RouteRecordMatcher | undefined
​
  /**
   * Resolves a location. Gives access to the route record that corresponds to the actual path as well as filling the corresponding params objects
   *
   * @param location - MatcherLocationRaw to resolve to a url
   * @param currentLocation - MatcherLocation of the current location
   */
  resolve: (
    location: MatcherLocationRaw,
    currentLocation: MatcherLocation
  ) => MatcherLocation
}

2. createRouterMatcher

这才是今天的主角呀,还好加上注释也才300行,不然我真的会吐。

入参:

  • routes:就是初始的路由列表呗;
  • globalOptions:天晓得这个全局选项是干啥子的嘞。看了眼上一篇,哦,原来是createRouter的选项参数options

接下来嘛,还是熟悉的配方,还是原来的味道。先把类型接口需要的方法都定义出来,然后组装在对象里返回。这套路不能说和createRouter毫无瓜葛,简直就是一模一样。那就排好队,一个一个来呗。

整了几个常量,虽然不明白为什么弄个matchers数组的同时,还要来个matcherMap,但是感觉似乎有点东西,后文应该会有答案吧。

/**
 * Creates a Router Matcher.
 *
 * @internal
 * @param routes - array of initial routes
 * @param globalOptions - global route options
 */
export function createRouterMatcher(
  routes: Readonly<RouteRecordRaw[]>,
  globalOptions: PathParserOptions
): RouterMatcher {
  // normalized ordered array of matchers
  const matchers: RouteRecordMatcher[] = []
  const matcherMap = new Map<RouteRecordName, RouteRecordMatcher>()
  globalOptions = mergeOptions(
    { strict: false, end: true, sensitive: false } as PathParserOptions,
    globalOptions
  )
    
  // 省略中间的函数体 ...
​
  // add initial routes
  // 添加初始路由
  routes.forEach(route => addRoute(route))
  return { addRoute, resolve, removeRoute, getRoutes, getRecordMatcher }
}

下面就是函数体的内容咯。

2.1 getRecordMatcher

getRecordMatcher就是以路由名称为keymatcherMap中取出matcher

​
​
  function getRecordMatcher(name: RouteRecordName) {
    return matcherMap.get(name)
  }

2.2 addRoute

router.addRoute只接收两个参数,而matcher.addRoute却有三个参数,那就是说matcher.addRoute还和别的函数有一腿咯。呸,海王!而且这个addRoute内容是真不少啊,那就挑重点了。

  • 开局一个常量isRootAdd,标记是否是添加在根路由下,后续会根据它来判断是否需要移除路由以防止多余的嵌套;

  • 将参数record处理为标准化的路由记录mainNormalizedRecord

  • 合并全局选项globalOptions和当前记录,得到新的选项options

  • 生成数组normalizedRecords来处理record的别名;

  • 处理normalizedRecords各成员的path,如果某个成员的path为通配符*,则会在dev环境下进行报错提示;

  • 调用createRouteRecordMatcher创建matcher

  • 如果存在原始的记录originalRecord,则当前为别名记录,需要放入原始记录的别名数组中;

  • 否则,normalizedRecords中的第一个成员就是原始记录,其余成员为别名;

  • 遇到顶层有名称的原始记录,则根据记录的名称来移除;

  • 递归mainNormalizedRecord.children

  • 通过insertMatcher来实际添加路由,只有matcher.record同时满足以下两个条件才会被添加:

    1. components中至少有一个component
    2. name或者redirect
function addRoute(
    record: RouteRecordRaw,
    parent?: RouteRecordMatcher,
    originalRecord?: RouteRecordMatcher
  ) {
    // used later on to remove by name
    const isRootAdd = !originalRecord
    const mainNormalizedRecord = normalizeRouteRecord(record)
    if (__DEV__) {
      checkChildMissingNameWithEmptyPath(mainNormalizedRecord, parent)
    }
    // we might be the child of an alias
    mainNormalizedRecord.aliasOf = originalRecord && originalRecord.record
    const options: PathParserOptions = mergeOptions(globalOptions, record)
    // generate an array of records to correctly handle aliases
    const normalizedRecords: (typeof mainNormalizedRecord)[] = [
      mainNormalizedRecord,
    ]
    if ('alias' in record) {
      const aliases =
        typeof record.alias === 'string' ? [record.alias] : record.alias!
      for (const alias of aliases) {
        normalizedRecords.push(
          assign({}, mainNormalizedRecord, {
            // this allows us to hold a copy of the `components` option
            // so that async components cache is hold on the original record
            components: originalRecord
              ? originalRecord.record.components
              : mainNormalizedRecord.components,
            path: alias,
            // we might be the child of an alias
            aliasOf: originalRecord
              ? originalRecord.record
              : mainNormalizedRecord,
            // the aliases are always of the same kind as the original since they
            // are defined on the same record
          }) as typeof mainNormalizedRecord
        )
      }
    }
​
    let matcher: RouteRecordMatcher
    let originalMatcher: RouteRecordMatcher | undefined
​
    for (const normalizedRecord of normalizedRecords) {
      const { path } = normalizedRecord
      // Build up the path for nested routes if the child isn't an absolute
      // route. Only add the / delimiter if the child path isn't empty and if the
      // parent path doesn't have a trailing slash
      if (parent && path[0] !== '/') {
        const parentPath = parent.record.path
        const connectingSlash =
          parentPath[parentPath.length - 1] === '/' ? '' : '/'
        normalizedRecord.path =
          parent.record.path + (path && connectingSlash + path)
      }
​
      if (__DEV__ && normalizedRecord.path === '*') {
        throw new Error(
          'Catch all routes ("*") must now be defined using a param with a custom regexp.\n' +
            'See more at https://next.router.vuejs.org/guide/migration/#removed-star-or-catch-all-routes.'
        )
      }
​
      // create the object beforehand, so it can be passed to children
      matcher = createRouteRecordMatcher(normalizedRecord, parent, options)
​
      if (__DEV__ && parent && path[0] === '/')
        checkMissingParamsInAbsolutePath(matcher, parent)
​
      // if we are an alias we must tell the original record that we exist,
      // so we can be removed
      if (originalRecord) {
        originalRecord.alias.push(matcher)
        if (__DEV__) {
          checkSameParams(originalRecord, matcher)
        }
      } else {
        // otherwise, the first record is the original and others are aliases
        originalMatcher = originalMatcher || matcher
        if (originalMatcher !== matcher) originalMatcher.alias.push(matcher)
​
        // remove the route if named and only for the top record (avoid in nested calls)
        // this works because the original record is the first one
        if (isRootAdd && record.name && !isAliasRecord(matcher))
          removeRoute(record.name)
      }
​
      if (mainNormalizedRecord.children) {
        const children = mainNormalizedRecord.children
        for (let i = 0; i < children.length; i++) {
          addRoute(
            children[i],
            matcher,
            originalRecord && originalRecord.children[i]
          )
        }
      }
​
      // if there was no original record, then the first one was not an alias and all
      // other aliases (if any) need to reference this record when adding children
      originalRecord = originalRecord || matcher
​
      // TODO: add normalized records for more flexibility
      // if (parent && isAliasRecord(originalRecord)) {
      //   parent.children.push(originalRecord)
      // }
​
      // Avoid adding a record that doesn't display anything. This allows passing through records without a component to
      // not be reached and pass through the catch all route
      if (
        (matcher.record.components &&
          Object.keys(matcher.record.components).length) ||
        matcher.record.name ||
        matcher.record.redirect
      ) {
        insertMatcher(matcher)
      }
    }
​
    return originalMatcher
      ? () => {
          // since other matchers are aliases, they should be removed by the original matcher
          removeRoute(originalMatcher!)
        }
      : noop
  }

2.3 removeRoute

这个就比addRoute简单多了嘛。接收的参数如果是name,就用matcherMap去找,如果是matcher,就从matchers里去找下标。

不仅要移除matchersmatcherMap中存储的matcher,还要从二者中递归移除matcher.childrenmatcher.alias中的所有matcher,这不是犯天条了要株连九族嘛。

  function removeRoute(matcherRef: RouteRecordName | RouteRecordMatcher) {
    if (isRouteName(matcherRef)) {
      const matcher = matcherMap.get(matcherRef)
      if (matcher) {
        matcherMap.delete(matcherRef)
        matchers.splice(matchers.indexOf(matcher), 1)
        matcher.children.forEach(removeRoute)
        matcher.alias.forEach(removeRoute)
      }
    } else {
      const index = matchers.indexOf(matcherRef)
      if (index > -1) {
        matchers.splice(index, 1)
        if (matcherRef.record.name) matcherMap.delete(matcherRef.record.name)
        matcherRef.children.forEach(removeRoute)
        matcherRef.alias.forEach(removeRoute)
      }
    }
  }

2.4 getRoutes

就返回matchers数组呗。好,原来这么简单,我这就去告诉别人我学会Vue Router的源码了!

  function getRoutes() {
    return matchers
  }

2.5 insertMatcher

看过addRoute的人都知道,addRoute中调用createRouteRecordMatcher生成matcher,而实际将matcher存起来的是insertMatcher

这下就解决开头的疑问了,matchers数组什么记录都能存,但是matcherMap只存原始记录。

  function insertMatcher(matcher: RouteRecordMatcher) {
    let i = 0
    while (
      i < matchers.length &&
      comparePathParserScore(matcher, matchers[i]) >= 0 &&
      // Adding children with empty path should still appear before the parent
      // https://github.com/vuejs/router/issues/1124
      (matcher.record.path !== matchers[i].record.path ||
        !isRecordChildOf(matcher, matchers[i]))
    )
      i++
    matchers.splice(i, 0, matcher)
    // only add the original record to the name map
    // matcherMap只存储原始记录
    if (matcher.record.name && !isAliasRecord(matcher))
      matcherMap.set(matcher.record.name, matcher)
  }

2.6 resolve

又是个飞流直下三千尺的函数,这么长我可以不看吗?周深再怎么一往情深,也远不及我此刻眉头皱得深。

存在有效的location.name时:

  • 根据参数location.name获取matcher,获取不到则抛出路由错误,且dev环境下会根据不合法的loaction.params来进行告警提示;
  • locationcurrentLocation中得到路由参数params,并字符串化为path

不存在有效的location.name但是存在有效的location.path时:

  • path取自location.path
  • 利用pathmatcher中拿到paramsname

最后,根据parent属性递归matcher,将得到的所有matcher倒序放进matched数组,使得父级位于前面;matched数组会与pathparamsname等属性在同一个对象中被返回。

function resolve(
    location: Readonly<MatcherLocationRaw>,
    currentLocation: Readonly<MatcherLocation>
  ): MatcherLocation {
    let matcher: RouteRecordMatcher | undefined
    let params: PathParams = {}
    let path: MatcherLocation['path']
    let name: MatcherLocation['name']
​
    if ('name' in location && location.name) {
      matcher = matcherMap.get(location.name)
​
      if (!matcher)
        throw createRouterError<MatcherError>(ErrorTypes.MATCHER_NOT_FOUND, {
          location,
        })
​
      // warn if the user is passing invalid params so they can debug it better when they get removed
      if (__DEV__) {
        const invalidParams: string[] = Object.keys(
          location.params || {}
        ).filter(paramName => !matcher!.keys.find(k => k.name === paramName))
​
        if (invalidParams.length) {
          warn(
            `Discarded invalid param(s) "${invalidParams.join(
              '", "'
            )}" when navigating. See https://github.com/vuejs/router/blob/main/packages/router/CHANGELOG.md#414-2022-08-22 for more details.`
          )
        }
      }
​
      name = matcher.record.name
      params = assign(
        // paramsFromLocation is a new object
        paramsFromLocation(
          currentLocation.params,
          // only keep params that exist in the resolved location
          // only keep optional params coming from a parent record
          matcher.keys
            .filter(k => !k.optional)
            .concat(
              matcher.parent ? matcher.parent.keys.filter(k => k.optional) : []
            )
            .map(k => k.name)
        ),
        // discard any existing params in the current location that do not exist here
        // #1497 this ensures better active/exact matching
        location.params &&
          paramsFromLocation(
            location.params,
            matcher.keys.map(k => k.name)
          )
      )
      // throws if cannot be stringified
      path = matcher.stringify(params)
    } else if (location.path != null) {
      // no need to resolve the path with the matcher as it was provided
      // this also allows the user to control the encoding
      path = location.path
​
      if (__DEV__ && !path.startsWith('/')) {
        warn(
          `The Matcher cannot resolve relative paths but received "${path}". Unless you directly called `matcher.resolve("${path}")`, this is probably a bug in vue-router. Please open an issue at https://github.com/vuejs/router/issues/new/choose.`
        )
      }
​
      matcher = matchers.find(m => m.re.test(path))
      // matcher should have a value after the loop
​
      if (matcher) {
        // we know the matcher works because we tested the regexp
        params = matcher.parse(path)!
        name = matcher.record.name
      }
      // location is a relative path
    } else {
      // match by name or path of current route
      matcher = currentLocation.name
        ? matcherMap.get(currentLocation.name)
        : matchers.find(m => m.re.test(currentLocation.path))
      if (!matcher)
        throw createRouterError<MatcherError>(ErrorTypes.MATCHER_NOT_FOUND, {
          location,
          currentLocation,
        })
      name = matcher.record.name
      // since we are navigating to the same location, we don't need to pick the
      // params like when `name` is provided
      params = assign({}, currentLocation.params, location.params)
      path = matcher.stringify(params)
    }
​
    const matched: MatcherLocation['matched'] = []
    let parentMatcher: RouteRecordMatcher | undefined = matcher
    while (parentMatcher) {
      // reversed order so parents are at the beginning
​
      matched.unshift(parentMatcher.record)
      parentMatcher = parentMatcher.parent
    }
​
    return {
      name,
      path,
      params,
      matched,
      meta: mergeMetaFields(matched),
    }
  }

createMatcher的函数内容到此就走向了终点,剩下的一个未解之谜是matcher的创建过程,而这都藏在createRouteRecordMatcher中。

createRouteRecordMatcher

在观摩createRouteRecordMatcher之前,最好是先了解一下类型接口RouteRecordMatcher,知己知彼。类型接口RouteRecordMatcher继承了PathParser

export interface PathParser {
  /**
   * The regexp used to match a url
   */
  re: RegExp
​
  /**
   * The score of the parser
   */
  score: Array<number[]>
​
  /**
   * Keys that appeared in the path
   */
  keys: PathParserParamKey[]
  /**
   * Parses a url and returns the matched params or null if it doesn't match. An
   * optional param that isn't preset will be an empty string. A repeatable
   * param will be an array if there is at least one value.
   *
   * @param path - url to parse
   * @returns a Params object, empty if there are no params. `null` if there is
   * no match
   */
  parse(path: string): PathParams | null
​
  /**
   * Creates a string version of the url
   *
   * @param params - object of params
   * @returns a url
   */
  stringify(params: PathParams): string
}
​
export interface RouteRecordMatcher extends PathParser {
  record: RouteRecord
  parent: RouteRecordMatcher | undefined
  children: RouteRecordMatcher[]
  // aliases that must be removed when removing this record
  alias: RouteRecordMatcher[]
}

那么现在可以看一看createRouteRecordMatcher。不看不知道,一看真下头。matcher的一大部分内容都来自于tokensToParser创建的parser,毕竟RouteRecordMatcher继承了PathParser。那这部分就不在本文深究了,不然过长了会让人既渴望又害怕。

export function createRouteRecordMatcher(
  record: Readonly<RouteRecord>,
  parent: RouteRecordMatcher | undefined,
  options?: PathParserOptions
): RouteRecordMatcher {
  const parser = tokensToParser(tokenizePath(record.path), options)
​
  // warn against params with the same name
  if (__DEV__) {
    const existingKeys = new Set<string>()
    for (const key of parser.keys) {
      if (existingKeys.has(key.name))
        warn(
          `Found duplicated params with name "${key.name}" for path "${record.path}". Only the last one will be available on "$route.params".`
        )
      existingKeys.add(key.name)
    }
  }
​
  const matcher: RouteRecordMatcher = assign(parser, {
    record,
    parent,
    // these needs to be populated by the parent
    children: [],
    alias: [],
  })
​
  if (parent) {
    // both are aliases or both are not aliases
    // we don't want to mix them because the order is used when
    // passing originalRecord in Matcher.addRoute
    if (!matcher.record.aliasOf === !parent.record.aliasOf)
      parent.children.push(matcher)
  }
​
  return matcher
}

今天这个代码就看到这里了,剩下的时间要去陪别的知识咯。

Flutter 效率:账号切换组件 NAccountSheet

作者 SoaringHeart
2024年5月19日 09:32

theme: fancy

一、需求来源

开发的一个项目目前有四种角色,每种角色进入的界面和各模块的权限都完全不同;测试阶段修改bug,每天都需要切换几十次,不胜其烦,随封装组件 NAccountSheet 提高效率。

核心思路:每次登录成功之后通过控制器调用添加当前账号密码,存储到本地,再次点击弹窗就是最新的账号列表;

效果如下:

Simulator Screenshot - iPhone 15 - 2024-05-19 at 09.17.22.png

二、使用示例

...
buildAccountSheet(),
...


// 账号切换
final accountSheetController = NAccountSheetController();

Widget buildAccountSheet() {
  return NAccountSheet(
    controller: accountSheetController,
    onChanged: (e) {
      accountController.text = e.key;
      pwdController.text = e.value;
    },
  );
}

void onClear() {
  accountSheetController.clear();
}

三、组件源码

//
//  NAccountSheetNewNew.dart
//  yl_health_app_v2.20.4.1
//
//  Created by shang on 2024/3/27 16:24.
//  Copyright © 2024/3/27 shang. All rights reserved.
//

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/cache/cache_service.dart';
import 'package:flutter_templet_project/extension/widget_ext.dart';

/// 账号选择器
class NAccountSheet extends StatefulWidget {
  const NAccountSheet({
    super.key,
    this.controller,
    this.items = const [],
    required this.onChanged,
    this.titleCb,
    this.subtitleCb,
  });

  /// 控制器
  final NAccountSheetController? controller;

  /// 预置数据列表(默认值空)
  final List<MapEntry<String, dynamic>> items;

  /// 改变回调
  final ValueChanged<MapEntry<String, dynamic>> onChanged;

  /// 子项标题显示
  final String Function(MapEntry<String, dynamic> e)? titleCb;

  /// 子项目副标题显示
  final String Function(MapEntry<String, dynamic> e)? subtitleCb;

  @override
  State<NAccountSheet> createState() => _NAccountSheetState();
}

class _NAccountSheetState extends State<NAccountSheet> {
  late List<MapEntry<String, dynamic>> items = widget.items;

  late MapEntry<String, dynamic>? current = items.isEmpty ? null : items.first;

  String get btnTitle => current == null ? "请选择账号" : current!.key;

  @override
  void dispose() {
    widget.controller?._detach(this);
    super.dispose();
  }

  @override
  void initState() {
    super.initState();
    widget.controller?._attach(this);

    var map = CacheService().getMap(CACHE_ACCOUNT_List) ?? <String, dynamic>{};
    if (map.isNotEmpty) {
      updateItems(map.entries.toList());
    }
  }

  @override
  Widget build(BuildContext context) {
    if (kReleaseMode) {
      return const SizedBox();
    }

    if (items.isEmpty) {
      return const SizedBox();
    }

    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          TextButton(
            style: TextButton.styleFrom(
              foregroundColor: Colors.red,
              padding: EdgeInsets.zero,
              tapTargetSize: MaterialTapTargetSize.shrinkWrap,
            ),
            onPressed: onChooseAccount,
            child: Text(btnTitle),
          ),
        ],
      ),
    );
  }

  void onChooseAccount() {
    showAlertSheet(
      message: Text(btnTitle),
      actions: items.map((e) {
        final title = widget.titleCb?.call(e) ?? e.key;

        return ListTile(
          dense: true,
          onTap: () {
            Navigator.of(context).pop();

            current = e;
            widget.onChanged(e);

            setState(() {});
          },
          title: Text(title),
          subtitle: Text(widget.subtitleCb?.call(e) ?? ""),
          trailing: Icon(
            Icons.check,
            color: current?.key == e.key ? Colors.blue : Colors.transparent,
          ),
        );
      }).toList(),
    );
  }

  void showAlertSheet({
    Widget title = const Text("请选择"),
    Widget? message,
    required List<Widget> actions,
  }) {
    CupertinoActionSheet(
      title: title,
      message: message,
      actions: actions,
      cancelButton: CupertinoActionSheetAction(
        isDestructiveAction: true,
        onPressed: () {
          Navigator.pop(context);
        },
        child: const Text('取消'),
      ),
    ).toShowCupertinoModalPopup(context: context);
  }

  void updateItems(List<MapEntry<String, dynamic>> value) {
    value.sort((a, b) => a.key.compareTo(b.key));
    items = value;
  }

  void updateCurrent(MapEntry<String, dynamic>? e) {
    current = e;
    debugPrint("current: ${current}");
  }
}

class NAccountSheetController {
  _NAccountSheetState? _anchor;

  void _attach(_NAccountSheetState anchor) {
    _anchor = anchor;
  }

  void _detach(_NAccountSheetState anchor) {
    if (_anchor == anchor) {
      _anchor = null;
    }
  }

  void onChooseAccount() {
    assert(_anchor != null);
    _anchor!.onChooseAccount();
  }

  void updateItems(List<MapEntry<String, dynamic>> items) {
    assert(_anchor != null);
    _anchor!.updateItems(items.reversed.toList());
  }

  /// 添加账户
  void addAccount({
    required String account,
    required String pwd,
  }) {
    assert(_anchor != null);
    var map = CacheService().getMap(CACHE_ACCOUNT_List) ?? <String, dynamic>{};
    map.putIfAbsent(account, () => pwd);

    _anchor?.items.forEach((e) {
      map.putIfAbsent(e.key, () => e.value);
    });

    CacheService().setMap(CACHE_ACCOUNT_List, map);
    updateItems(map.entries.toList());
    _anchor?.updateCurrent(MapEntry(account, pwd));
  }

  void clear() {
    CacheService().remove(CACHE_ACCOUNT_List);
    updateItems([]);
    _anchor?.updateCurrent(null);
  }
}

总结

1、核心是基于极简封装的原则通过将每对账号密码转为 MapEntry,添加到字典存储到本地,实时更新,极其简单;

2、此组件是效率组件,为工作提效随手开发;如果你的app只有一种账号类型,请湖绿;

3. 姐妹篇:域名选择器 NOriginSheet

github

蔚来的故事,让李斌讲完了?

2024年5月19日 08:51

出品丨虎嗅汽车组

作者丨周到

头图丨视觉中国

 

尽管蔚来的第二品牌乐道汽车在其品牌发布会上尽可能地塑造了轻松欢乐的氛围,但资本市场并没有给面子。

 

美东时间5月15日,蔚来股价在开盘爬上5.8美元之后,迅速跌去了超过10%,最低到了5.2美元。以其市值计算,最大跌幅达到了近10亿美元的水平。一些人就此评价称,资本市场对于乐道品牌以及其首款新车乐道L60并不买账,当前蔚来处于“利好尽出”的状态。

 

换言之,蔚来已经把故事讲完了。

 

但在笔者看来,现在就对蔚来和乐道下这个定义还有些为时过早。


蔚来,为什么必须要自己做乐道?

 

“乐道品牌在2020年开始筹备,2021年正式启动。”李斌在5月16日的媒体沟通会上说道。


在此之前,李斌执掌下的蔚来已经试图联手其他中国汽车集团进行了多轮尝试,但最终都没有下文。

 

早在2017年4月,才发布了品牌的蔚来就与长安汽车签署了战略合作协议,宣布将采用组建合资公司的方式,打造全新品牌的电动车。随后的12月,蔚来还与广汽集团达成了签订了类似的战略合作协议。


 

不过,这两家公司都没有按照当初的预定走向发展。前者随着蔚来的淡出,改头换面成了如今的阿维塔;后者则成了合创。

 

从表面上看,这两场“和亲”之所以没成功,在于两家传统车集团和蔚来在OKR的“O”(目标)上,压根就没有拉齐颗粒度。


长安和广汽当初选择合作,其目的在于借助蔚来实现自己的品牌向上,在自己的既有体系之外,通过吸纳社会乃至全球资本打造一个豪华汽车品牌。而蔚来则希望通过和拥有平价汽车研发、生产、制造和销售服务经验的传统汽车集团合作,组建品牌定位相比自身更低,但更走量的产品。

 

但从深层内里分析,对于在2017年还一辆车都没交付的蔚来而言,谈向外输出技术和工程能力还为时太早。李斌就对笔者透露,当时蔚来的第一代整车平台(NT 1.0)的定位实在太高了成本降不下来。


以2018年交付的初代ES8为例,这款车的白车身铝合金含量高达96.4%,抗扭刚度为44140Nm/deg,达到了中国汽车品牌的天花板级别。要知道,今年3月上市的理想MEGA,其抗扭刚度也才达到了44000Nm/deg,铝合金含量更是没法比。


第一代蔚来ES8

 

这直接带来的结果,就是蔚来在成本控制上的失控。从宏观上看,9月美股IPO直到2020年Q2,蔚来单车毛利率一直是负数,卖一辆亏一辆。而从微观细节上看,如果合资伙伴用NT 1.0为技术底座卖车,初期财务压力太大。“就算用减配再减配的‘猴版’,也没法满足合资公司的要求”。

 

于是,这个主意就伴随着李斌成为“2019年最惨的男人”后被雪藏。但在李斌的布局里,平价汽车品牌是必须要做的事情。因此在2020年蔚来NT 2.0平台新车完成了规划,NT 3.0平台也开始定义后,该公司再度将第二品牌的工作提上了日程。而在此时,蔚来终于具备了向外输出技术和工程能力。

“蔚来过去几年将研发业务线转变为了能力中心,完成了非常重要的组织变革。”李斌说道。

 

蔚来需要一个走量的品牌。


乐道L60

 

从盈利角度看,蔚来只靠自己很难实现扭亏为盈。在过去的2023年,蔚来卖出了16万辆新车,实现了556.2亿元的总营收。而其研发成本高达134.3亿元,占其总收入的24%,这直接导致了207.2亿元的全年净亏损。

 

相比之下,宝马在2023年的全球销量为255万辆,研发费用占收入比5%,为75.38亿欧元。而奔驰全年销量为249.16万辆,研发费用为100亿欧元,占收入比重6.5%。

 

显然,正是充足的全球销量基本盘,让宝马和奔驰能从容地拥有比蔚来多几倍的研发资金。因此,蔚来要想维持长期以来的研发强度,必须提升产品销量。于是,第二品牌在蔚来2020年“缓过气”来之后,马上就被提上了日程。

 

艾铁成被选作第二品牌的负责人。这位先后在宝洁、洲际酒店集团、迪士尼和Wework中国任职的资深职业经理人,是李斌耗费几个月时间亲自挖来的,还是蔚来二号位,公司联合创始人兼总裁秦力洪的多年好友。


“艾铁成是我2001年入职宝洁后的‘带教师父’。”秦力洪对笔者说道。


左侧为秦力洪,右侧为艾铁成

 

早在2015年,艾铁成还在筹备上海迪士尼乐园开业时,便与蔚来有过接触。当时后者正在组建用户体验团队,艾铁成就为“如何设计更加完美的用户体验旅程”帮过忙。在2016年,他还应邀在蔚来进行过内部分享。随后,艾铁成还成为了最早一批蔚来社区成员,并作为107号车主下单购买了初代ES8。

 

最终在2021年进入蔚来体系负责第二品牌后,艾铁成应要求“闭麦”了3年,直到在5月9日蔚来第50万辆新车下线仪式后才正式对外亮相。

 

不过正如外界所预料的,这位高管并没有直接成为品牌全部业务的“话事人”。无论是9号的媒体沟通还是15号的乐道发布会上,李斌都是以“介绍人”的身份,向外界引出了艾铁成。

 

事实上,连李斌自己都坦言,对于乐道的发展,他将从方向上帮助其“持续校准”。


乐道的座标系

 

对于乐道品牌的定义,李斌给的相当明确:合家欢乐,持家有道。有意思的是,作为“战略思维”的爱好者,他甚至还给出了一个公式,用于计算乐道这样的家庭用车“价值”:



如图所示,他和乐道认为,家庭用车的价值评估模型应当是一个除法。其中分子部分是家庭用户最所在意的产品力,分别是安全、空间、智能座舱、补能、驾乘和智能驾驶。其中,安全和空间排在第一,驾乘体验和智驾排在最后。

 

而在分母部分,一辆家用车综合成本应当降到最低,而残值也应当得到最大程度的保护。只有这样,车辆的价值才能得到最大化。而具体到预售价21.99万元起的乐道首款新车——L60上,体现的就是在安全、空间和能耗方面的追求。


在“偷”空间和能耗方面,L60做得相当极致

 

有意思的是,在整个发布会的过程中,李斌和艾铁成完全没有提到“性能”两个字,这辆车0 ~ 100公里/小时的加速成绩更是只能等到9月正式上市后才能知晓。要知道,关于L60的驾控,这次发布会上只公布了车辆悬架在舒适性方面的表现,以及整款车在转弯灵活度方面取得的成绩。


丰田RAV4、MINI COUNTRYMAN、乐道L60和特斯拉Model Y的转弯半径对比

 

这背后的原因,在于李斌对于乐道品牌“专注度”的要求,“我在乐道就是‘打酱油’的,每周会有一天时间放在新品牌内部的产品会上。但我的参与,更多还是让大家要坚持回归到家庭用户的场景中,要聚焦,不然他们有时候会被外面带歪节奏。”

 

乐道此前有过一版造型方案,是由一位在欧洲慕尼黑分部的设计师主导开发的。“那个造型我们特别喜欢,空间也特别好。但在风阻方面不够极致,考虑的比较少,因此被否了。”

 

换言之,就是不够“持家有道”。随后,蔚来将车型的负责人替换成了前宾利外观设计师Raul Pires,最终打造出了如今大家看到的,外观没有太多惊喜但在风阻系数方面做到极致的L60。

 

尽管需要额外消耗半年时间,但李斌依旧坚持了决定。其中的根本原因在于,相比较立足高端汽车市场,在外观设计和车型定位上聚焦于美观、个性并对标BBA的蔚来,李斌给乐道定义的学习对象除了特斯拉之外,还有丰田。

 

正如前文展示的发布会PPT上,L60对标的产品不仅有Model Y,还有一汽丰田的RAV4(中文名“荣放”)。

 


在李斌看来,RAV4尽管车长才有4.6米且属于紧凑级SUV,但其和丰田才是乐道品牌的学习榜样。除了前文提到的,相比较同级竞品优秀的空间、舒适性、燃油经济性和城市驾驶的便捷性。值得一提的是,这款车在外观上也非常有设计感,在街上很有辨识度。


正是这些原因,让RAV4成为了近几年全球销量最高的乘用车之一。这款车在中国如果加上和其同平台的广汽丰田锋兰达,直到今天还能在中国斩获3.25万辆月销量。而放到全球市场,这款车在2022年更是实现了100万辆的交付量排名第一,直到2023年被特斯拉Model Y超越。

 

除此之外,李斌还在交流中告诉笔者,乐道要向丰田学习的除了产品定义,还有精益管理。他透露,在他们内部开会和决策过程中,对于前文提到的公式表述远比对外界展示的要详细得多,“我们是按照大模型的算法来做的这套体系,全部12个因子都是定量的。我们需要通过精准计算每一块钱的投入,到底能给用户带来多少的价值。”

 

不过对于乐道L60具体的销量目标,李斌、秦力洪和艾铁成均没有对外透露。要知道,虽然国内20万元级的中型SUV车型很多,但纯电动版本的极少。事实上除了特斯拉Model Y,无论是比亚迪的宋L还是智己的LS6,目前的稳态销量都不到5000辆。


因此,乐道能否依托蔚来的技术、换电体系以及品牌知名度帮助自己乃至于电动车打开局面,去跟燃油车乃至混动车中抢下市场份额,真的是一件说不好的事情。这不仅取决于该车型最终的内饰细节以及产品的综合表现能否让消费者满意,更与乐道的销售体系以及团队建设有着直接关系。


这样看来,乐道这个背着走量压力的“家中老二”能否快速帮助家里“脱贫致富”,还需要李斌和他的团队付出更多资源。


下一步,乐道打算怎么走?

 

按照乐道方面的规划,L60将在今年9月上市交付。对于之所以选择提前4个月进行品牌的发布,李斌和艾铁成表示一般的家庭用户购车周期是3 ~ 6个月,“我们需要先跟大家混个脸熟,然后才好让大家把我们放入考虑名单里。”

 

根据规划,在上市之前该品牌将布局超过200个商超门店和位于汽车园区的门店。初期,乐道将在大多数城市和蔚来共用交付中心,后期或随不同地区的交付节奏建设独立的交付体系。

 

而对于外界关心的,乐道与蔚来用户权益的差异,李斌和秦力洪也进行了部分说明。在该公司广为人知的积分体系层面,两个品牌在积分和商城层面是打通的,毕竟都是同一个上市公司框架内。同时在售后和服务体系方面,乐道和蔚来也共用一套维修保养门店,并可以通用换电站(乐道仅能使用3、4代换电站,目前已经超过1000个)。


 

但是,乐道用户在服务层面的权益相比蔚来势必有所缩减。目前可知,前者不能没有后者用户带领下进入蔚来中心(NIO House),一些蔚来服务无忧权益包中的项目也不会对乐道用户开放。

 

“看不见的部分,双方尽量共用;看得见的部分,两者尽量区隔。”这可以理解为,蔚来对于乐道用户服务的运营思路。

 

不过,由于乐道品牌目前规划的3款电池包里,起码有60kWh和90kWh两种规格的配置和蔚来目前的75kWh、100kWh不同。因此,在蔚来三代和四代换电站里的22、23块电池舱位将如何分配,则成为了一个待解的问题。更不用说,蔚来如今还推出了150kWh的电池包。

 

但李斌对此表示乐观。他透露,目前该公司的能源团队在规划四代站的时候采用了模块化的设计思路,能够根据周边用户需求和场地条件,提供更多电池舱位或换电工位选项,进而提供不同的方案。“如果用的人多,我巴不得呢。”

 

关于乐道L60的产品情况,目前能知道的信息就是这些。但对于乐道规划的三款车型中的第二款,蔚来的高管们在5月16日的交流中也透露了一些消息。目前,这款车已经基本确定为一款拥有6/7座可选版本的中大型纯电SUV,预计将在2025年上市交付。“跟理想L8或L9一个级别,价格便宜十几万。”

 

同样的,这款车的设计也继续由Raul Pires操刀完成。据了解,蔚来还不是这位知名设计师在中国服务的第一家企业。早在2021年,在长城汽车担任造型高级副总裁的他,就曾在上海车展期间结合坦克700谈过长城汽车的设计灵感。


右侧为时任长城汽车设计副总裁Raul Pires


“当年,Raul Pires来华工作,在北京落地后直接就到了保定,完全没有在一线城市的生活经历。”一位知情人士这样对笔者说道,“我当时就跟他说,你应该多到中国大城市走走。”

 

如今,已经定居上海的他,显然有更多机会来展示对于设计上的创意。此外Raul Pires还有一个被蔚来看重的特质,便是他出生成长于巴西——一个和中国一样的发展中大国。

 

“Raul Pires并不是那种土生土长的欧洲设计师,这是非常可贵的点。这有助于他对于发展中国家用户对于消费升级的需求,有着更加深入的理解和洞察。”秦力洪对笔者说道。

 

按照目前的规划,乐道品牌还会有第三款车在2026年推出,但其还没有最终确定。不过等到那时,蔚来的第三品牌——代号“萤火虫”也将继续推出。秦力洪透露,该品牌将更加聚焦于年轻人的购车需求,车型尺寸会相对更小,“类似MINI之于宝马”。同时,该品牌的运营可能会更加轻量化,“会直接在蔚来门店中展示和销售”。

 

从这个角度来看,蔚来的故事远远没有到讲完的时候。如果一切顺利,明年的这个时候,蔚来也许就成为了一个拥有三个品牌同时运行的汽车集团了。

  

写在最后:

 

在乐道品牌的发布会上,细心的观众可能发现,乐道L60和蔚来去年年底发布的新世代旗舰ET9,其实有着大把的相似之处。两者采用了同样的900V高压架构,横向的中控屏,以及同样的4D毫米波雷达。


乐道L60和蔚来ET9的中控屏,都采用了和当前量产车不同的横屏方案

 

也就是说,两者都属于蔚来的NT 3.0平台车型。只不过ET9最先发布,而乐道L60最先交付。不过这个信息,在发布会上被有意忽略了。

 

在传统燃油车时代,一套新的整车架构、设计语言或动力模式,一般优先在旗舰车型或产品线上实现换代,随后再在定位和价格更低的车型逐步搭载。然而蔚来在这一次,率先打破了这个惯例。

 

对此,未来的蔚来ET9用户是否有意见,笔者不得而知。但对于首次运行多个品牌的蔚来而言,这很可能将成为一个需要解决的问题。

下载虎嗅APP,第一时间获取深度独到的商业科技资讯,连接更多创新人群与线下活动

节奏狂潮:打造一个互动式鼓乐富应用

作者 理tan王子
2024年5月18日 23:04

theme: juejin highlight: an-old-hope

image.png

概述

随着 HTML5 技术的进步,网页开发者能够创建越来越多互动性强、富有创意的应用。这篇文章将带你通过一个名为drump kit的互动式音乐鼓乐项目,深入了解富应用的概念和实现方法。我们将详细讲解如何使用 HTML、CSS 和 JavaScript 构建一个可以通过键盘敲击模拟鼓乐的网页应用,让我们来体验一下赛博鼓手

目录

一、富应用的概念

二、示例项目概述

三、HTML结构

四、CSS样式

五、 JavaScript交互

六、总结

一、富应用的概念

富应用(Rich Internet Application,RIA)是指那些通过现代网页技术提供与桌面应用程序相似用户体验的网页应用。富应用与传统网页应用的区别在于其更强的交互性、更好的多媒体支持和优化的性能。以下是富应用的主要特点:

  • 交互性强:通过 JavaScript 和 Ajax 技术,富应用实现了流畅即时的用户交互。
  • 多媒体支持:HTML5 的音频、视频标签以及 Canvas API,使得富应用可以轻松集成音视频和动画元素。
  • 性能优化:富应用通常使用异步加载数据和内容,减少页面刷新次数,提升用户体验。
  • 响应式设计:使用 CSS3 媒体查询和 Flexbox 等布局技术,确保在不同设备和屏幕尺寸下提供一致的体验。

二、示例项目概述

drump kit是一个互动式音乐鼓乐富应用,用户可以通过按键盘上的按键来模拟不同的鼓声。该项目展示了 HTML、CSS 和 JavaScript 的综合应用,特别是如何利用这些技术创建一个用户体验良好的富应用。

项目文件结构如下:

  • index.html:定义网页结构的 HTML 文件。
  • common.css:包含样式定义的 CSS 文件。
  • common.js:实现交互功能的 JavaScript 文件。
  • sounds 文件夹:存放鼓乐声音文件。

三、HTML结构

在本项目中,我们使用 div 元素创建了代表每个鼓键的块,并使用 data-key 属性存储每个键的键码,同时添加了 audio 元素以便在按键时播放相应的声音。

标签结构层次

<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>drump kit</title>
    <link rel="stylesheet" href="./common.css">
</head>
<body>
    <div class="keys">
        <div class="key" data-key="65">
            <div class="title">A</div>
            <span class="sound">clap</span>
            <audio src="sounds/clap.wav" data-key="65"></audio>
        </div>
        <div class="key" data-key="83">
            <div class="title">S</div>
            <span class="sound">hithat</span>
            <audio src="sounds/hithat.wav" data-key="83"></audio>
        </div>
        <div class="key" data-key="68">
            <div class="title">D</div>
            <span class="sound">kick</span>
            <audio src="sounds/kick.wav" data-key="68"></audio>
        </div>
        <div class="key" data-key="70">
            <div class="title">F</div>
            <span class="sound">openhat</span>
            <audio src="sounds/openhat.wav" data-key="70"></audio>
        </div>
        <div class="key" data-key="71">
            <div class="title">G</div>
            <span class="sound">boom</span>
            <audio src="sounds/boom.wav" data-key="71"></audio>
        </div>
        <div class="key" data-key="72">
            <div class="title">H</div>
            <span class="sound">ride</span>
            <audio src="sounds/ride.wav" data-key="72"></audio>
        </div>
        <div class="key" data-key="74">
            <div class="title">J</div>
            <span class="sound">snare</span>
            <audio src="sounds/snare.wav" data-key="74"></audio>
        </div>
        <div class="key" data-key="75">
            <div class="title">K</div>
            <span class="sound">tom</span>
            <audio src="sounds/tom.wav" data-key="75"></audio>
        </div>
        <div class="key" data-key="76">
            <div class="title">L</div>
            <span class="sound">tink</span>
            <audio src="sounds/tink.wav" data-key="76"></audio>
        </div>
    </div>
    <script src="./common.js"></script>
</body>
</html>

四、CSS 样式

CSS 用于为网页添加样式,使其更加美观。我们使用了 Flexbox 来实现键的水平和垂直居中布局,并添加了过渡动画和按键按下时的效果。

html {
    font-size: 10px; /* CSS inherit */
    background: url('./background.jpg') bottom center;
    background-size: cover;
}
html, body {
    height: 100%;
}

.keys {
    display: flex; /*父容器设置为弹性*/
    min-height: 100%;
    align-items: center;/*垂直居中*/
    justify-content: center;/*水平居中*/
}
.key {
    border: 4px solid black;
    border-radius: 5px;
    margin: 10px;
    font-size: 15px;
    padding: 10px 5px;
    /* 过渡动画 */
    transition: all .7s ease;
    width: 100px;
    text-align: center;
    color: white;
    background: rgba(0, 0, 0, .4);
    text-shadow: 0 0 5px black; /* 立体感 */
}

.key .title {
    font-size: 40px;
}
.sound {
    font-size: 12px;
    text-transform: uppercase;
    letter-spacing: 1px;
    color: #ffc600;
}

.playing {
    transform: scale(1.1);
    border-color: #ffc600;
    box-shadow: 0 0 5px #ffc600;
}

五、JavaScript 交互

JavaScript 负责添加网页的交互功能。在这个项目中,我们监听 keydown 事件,当按下键时,我们通过 data-key 属性找到相应的 div 元素,并添加 playing 类来触发 CSS 动画,同时播放对应的音频。具体请看如下注释。

const keys = document.querySelectorAll('.key');

// for (let i = 0; i < keys.length; i++) {
//     let key = keys[i];
   
// }

// 定义播放声音的函数
const playSound = (event) => {
    // 获取按键的建码
    const keyCode = event.keyCode;
      // 选择与键码对应的鼓键元素和音频元素
    const ele = document.querySelector(`.key[data-key="${keyCode}"]`);
    const audio = document.querySelector(`audio[data-key="${keyCode}"]`);
   // console.log(ele);
     // 如果找到对应的元素
    if (ele && audio) {
        // 添加 'playing' 类触发动画效果
        ele.classList.add('playing');
        // 将音频的播放时间重置为开始
        audio.currentTime = 0; 
        // 播放音频
        audio.play();
        
        setTimeout(() => {
            // 在 800 毫秒后移除 'playing' 类,结束动画效果
            ele.classList.remove('playing');
        }, 800);
    }
};

window.addEventListener('keydown', playSound); // 监听键盘按键事件

这便是我们完成的一个简单而又有趣的富应用!如果想获取源码可以到github获取。

image.png

总结

通过这个项目,展示了如何利用 HTML5、CSS 和 JavaScript 构建一个简单的富应用。富应用利用现代网页技术,实现了与桌面应用程序类似的用户体验。通过结构化的 HTML 来定义页面布局,使用 CSS 添加样式和动画效果,并使用 JavaScript 添加交互功能,这种富应用不仅能提高用户体验,还能展示 HTML5 的强大功能。希望这个项目能帮助各位更好地理解和掌握 HTML5 的使用技巧,并激发各位创建更多有趣和有用的网页应用!

逆天的仿站软件,只需网站截图,复制和网站一样的效果

作者 在呼吸124
2024年5月18日 22:08

可以看到功能非常强大,你只需一张你喜欢的网页截图,他会帮你生成和网页一样的html和css代码,内容他会自动替换,从此解放双手。

效果展示

第一、去github下载开源项目

开源项目:GitHub - abi/screenshot-to-code

注:这是别人的开源项目

进去后按步骤点击

第二、解压开源项目

下载成功后解压,解压后cmd进入当前目录(我把它下载到d:)

第三、通过pip install poetry,等它下载完

第四、通过下面这些命令启动后端

cd backend
echo "OPENAI_API_KEY=sk-your-key" > .env
poetry install
poetry shell
poetry run uvicorn main:app --reload --port 7001

注意:OPENAI_API_KEY=sk-your-key,的sk-your-key填你的OpenAI API 密钥

通过poetry install下载报错

去官网下载最新版,然后去控制面板删除以前的版本,如果下载了最新的,一般有两个python版本,卸载以前的就好,

更改环境变量删除原来的,保留最新的(这是我原来python环境路径,需要删除,换最新的下载路径)

第五、后端启动成功的标准

第六、我们需要再开一个cmd还是这个目录

第七、运行前端,执行一下命令

cd frontend
yarn
yarn dev

成功页面

最后打开http://localhost:5173以使用该应用程序。

vite详解

2024年5月18日 21:45

什么是构建工具

首先我们要知道浏览器只认识html,css,js,但在企业级项目中会用到哪些功能?

  1. typescript:遇到ts文件我们需要使用tsc将ts代码转为js代码
  2. React/Vue:安装react-compiler / vue-compiler,将我们的".jsx" / ".vue"转为render函数
  3. less/sass/postcss/cpmponent-style:安装less-loader,sass-loader等一系列工具
  4. 语法降级:babel->将新语法装维旧浏览器支持的语法
  5. 体积优化:uglif-js->将代码进行压缩变成体积更小性能更高的文件
  6. ...
    在上述中我们改一点东西都非常麻烦,有一个东西可以把这些功能都集成,我们只需要关心写代码就好了,只要代码有改动就会自动去执行上述操作,这个东西就叫构建工具

构建工具做了什么?

  1. 模块化开发:支持从node-modules里引入代码+多种模块化支持
  2. 处理代码兼容性:如babel语法降级,sass、ts语法转换。注:不是构建工具本身的功能,是构建工具将这些语法对应的处理工具集成进来自动化处理
  3. 提高项目性能:压缩文件,代码分割
  4. 优化开发体验:
    • 建工具会帮你自动监听文件的更新,当文件变化后自动调用对应的集成工具进行重新打包,在浏览器重新运行(热更新 hot replacement)
    • 发服务器:解决跨域问题

总结:构建工具可以让我们不用关心代码在浏览器中如何运行,只需要关心我们的开发怎么写的爽就可以了

vite相较于webpack的优势

当我们开始构建越来越大型的应用时,需要处理的 JavaScript 代码量也呈指数级增长。包含数千个模块的大型项目相当普遍。基于 JavaScript 开发的工具就会开始遇到性能瓶颈:通常需要很长时间(甚至是几分钟!)才能启动开发服务器,即使使用模块热替换(HMR),文件修改后的效果也需要几秒钟才能在浏览器中反映出来。如此循环往复,迟钝的反馈会极大地影响开发者的开发效率和幸福感。 因为webpack支持多种模块化,一开始必须统一模块化代码,所以意味需要将所有依赖读一遍,造成的结果就是webpack需要很长的时间才能启动。

vite与vite脚手架

当我们敲了一个npm create vite@latest

  • 其实是帮我们全局安装了create-vite(vite脚手架)
  • 直接运行create-vite bin目录下的执行配置
    在vite/webpack项目中,会有node_modules文件夹,我们知道浏览器只识别相对路径和绝对路径,为什么es官方不在我们导入非相对路径和绝对路径时默认帮我们搜寻node_modules呢?
  1. 比如当安装了lodash,那在lodash中回依赖其他东西,这样在浏览器运行时就会处理过多的请求,严重降低性能
  2. 那为什么CommonJS就可以呢,因为CommonJS是运行在服务端的(这样找资源就不是通过忘了请求而是通过本地文件)

vite的依赖构建

当遇到非相对路径和绝对路径时是怎么处理的呢?

//寻找依赖的过程是自当前目录依次向上查找的过程,直到搜寻到为止。
import _ from 'lodash' 
import __vite__cjsIport0_lodash from "/node_modules/.vite/deps/lodash.js?v=eb94534"

依赖构建
vite会找到对应的依赖,然后调用esbuild,将其他规范的代码转为esmodule规范,再放在当前目录下。同时对esmodule规范各个模块进行统一集成
解决了三个问题:

  1. 不同的第三方包会有不同的规范
  2. 对路径上的处理可以直接使用.vite/deps,方便路径重写
  3. 网络多包传输的性能问题(也是原生esmodule规范不敢支持node_modules的原因之一),有了依赖预构建后,无论有多少export/import,vite都会尽量将他们集成为一个或几个模块

vite配置

语法提示

vite.config.js
//默认写法是没有语法提示的
export default {
    optimizeDeps:{
        exclude:[]//将指定数组中的依赖不进行依赖预构建
    }
}
//加上语法提示
//第一种
import {defineConfig} from "vite"
export default definConfig({
})

//第二种
/** @type {import('vite').UserConfig} */ 
export default { 
}

环境区分

import { defineConfig } from "vite";
import viteBaseConfig from "./vite.base.config.ts";
import viteDevConfig from "./vite.dev.config.ts";
import viteProdConfig from "./vite.prod.config.ts";
//策略模式
const envResolver = {
  build: () => ({ ...viteBaseConfig, ...viteProdConfig }),
  serve: () => Object.assign({}, viteBaseConfig, viteDevConfig),
};
export default defineConfig(({ command }) => envResolver[command]());

环境变量
补充:为什么vite.config.js可以书写成esmodule的形式,是因为vite在读取这个文件时会率先node去解析文件语法,如果发现是esmodule规范就会替换为CommonJS规范
在vite中的环境变量处理:内置了dotenv第三方库,dotenv会自动读取.env文件,并解析文件中对应的环境变量并注入到process(node内置的进程管理)对象下,但是vite考虑到和其他配置文件的冲突问题,不会直接注入到process对象下,vite提供了一些补偿措施:可以调用vite的loadEnv来手动确认env文件

import { defineConfig, loadEnv } from "vite";
import viteBaseConfig from "./vite.base.config.js";
import viteDevConfig from "./vite.dev.config.js";
import viteProdConfig from "./vite.prod.config.js";
/**
 * .env:所有环境都需要用到的环境变量
 * .env.development:开发环境需要用到的环境变量(默认情况下vite将开发环境取名为development)
 * .env.production:生产环境需要用到的环境变量(默认情况下vite将开发环境取名为production)
 * process.cwd():返回当前node进程的工作目录
 */
/**服务端
 * mode是可以设置的,当我们运行项目时,yarn dev => 其实相当于 yarn dev --mode development,当我们想要更改环境名是就只需要:yarn dev --mode “你需要的环境名”
 * 当我们调用loadEnv是会做以下几件事:
 *  1.直接找到.env文件并解析其中的环境变量放进一个对象里
 *  2.将传进来的mode进行拼接:```.env.development```,并根据提供的目录去解析对应的文件放进一个对象,我们可以理解为:有两个对象 => {...env,...envdevlopment}
 */
/**客户端
 * vite会将对应环境变量注入到import.meta.env中。
 * vite为了防止隐私性的变量直接送到import.meta.env中做了一个拦截,必须是以VITE开头的才会注入其中
 */
//策略模式
const envResolver = {
  build: () => ({ ...viteBaseConfig, ...viteProdConfig }),
  serve: () => Object.assign({}, viteBaseConfig, viteDevConfig),
};
export default defineConfig(({ command, mode }) => {
  // 根据当前工作目录中的 `mode` 加载 .env 文件
  // 设置第三个参数为 '' 来加载所有环境变量,而不管是否有 `VITE_` 前缀。
  // 第二个参数不是必须使用process.cwd()
  const env = loadEnv(mode, process.cwd(), "");
  console.log("//////env", env);
  return envResolver[command]();
});

在vite中处理css
vite天生支持对css文件的直接处理

  1. 在读取.css文件时会使用fs模块去读取.css文件内容
  2. 创建一个style标签,将.css文件中的内容copy进style标签里
  3. 将style标签插入到index.html的head中
  4. 将该css文件直接替换为js脚本(方便热更新或css模块化),同时设置Content-Type为javascript,从而让浏览器以js脚本的形式来执行css后缀的文件

以上方式在协同开发时就会出现样式覆盖问题(类名重复),那么怎么解决呢?
cssmodule就是用来解决这个问题的,大概原理:

  1. 以.module.css结尾的文件(一种约定,表示开启css模块化)
  2. 会将所有的类名进行一定规则的替换,同时创建一个映射对象
  3. 将替换后的内容写进style标签里放入head中
  4. 将.module.css结尾的文件的内容全部抹除,替换成js脚本
  5. 将创建的映射对象在脚本中进行默认导出

vite.config.js中的配置

import { defineConfig } from "vite";
export default defineConfig({
  css: {
    modules: {
      //对css行为进行配置,最后会丢给postcss modules
      localsConvention: "camelCaseOnly", //修改生成的配置对象的key的展示形式(驼峰还是中划线)
      scopeBehaviour: "local", //配置当前的模块化行为是模块化还是全局化,模块化(默认值:local)可以保证有不同的hash值来控制样式类不被覆盖
      generateScopedName: "[name]_[local]_[hash:5]", //生成类名的规则
      // generateScopedName: (name, filename, css) => {
      //   //配置成函数后,返回值就决定了最终显示的类型
      //   return `${name}`;
      // },
      hashPrefix: "hash", //生成hash会根据类名加一些其他字符串去进行生成
      globalModulePaths: [], //代表不想参与到css模块化的路径
    },
    //主要是用来配置css预处理器的一些全局参数
    preprocessorOptions: {
      //key + config key ->预处理器名
      less: {
        math: "always",
        //全局变量,(有一些使用less全局变量的时候会建一个全局变量文件,在要使用的地方进行引入,其实大可不必,可以直接在这里进行配置)
        //在项目中可以新建一个文件定义一个主题样式函数,在这里导入使用即可
        globaVars: {
          mainColor: "red",
        },
      },
    },
    //开启文件索引,这样在报错时可以定位到源文件所在位置
    devSourcemap: true,
    //配置postcss
    postcss: {
      //支持css变量和一些未来的css语法,自动补全
      plugins: [postcssPresetEnv],
    },
  },
});

如果你不想在vite.config.js文件中配置postcss,你也新建一个文件来单独配置

//postcss.config.js
const postcssPresetEnv = require("postcss-preset-env");
module.exports = {
  plugins: [postcssPresetEnv],
};

加载静态资源

除了动态API以外,其他基本都被视作静态资源,在vite项目中,引入的json数据会自动解析为对象,这样我们就可以对json数据进行按需引入

vite在生成环境对静态资源的处理

使用过项目中的打包的同学应该都知道,打包后的静态资源是有hash的,但是为什么要加这个呢?
首先我们要知道用hash的目的:拼上hash的字符大概率的是唯一的,浏览器又具有缓存机制(只要文件名不改,那么它就会使用缓存),在vite项目中打包时,只要静态资源发生改变这个文件名的hash值就会改变,这样就可以让我们更好的去控制浏览器的缓存机制。

vite插件

什么是插件?
插件就是在生命周期的不同阶段去调用不同的插件以达到不同的目的

手写vite-aliases插件 vite-aliases:可以帮我们自动生成路径别名:检测当前目录下包括src在内的所有文件夹,并帮我们去生成别名

const fs = require("fs");
const path = require("path");
function getTotalSrcDir(keyName) {
  const result = fs.readdirSync(path.resolve(__dirname, "../src"));
  const difdResult = diffDirAndFile(result, "../src");
  const resolveAliasesObj = {};
  difdResult.dirs.forEach((dirName) => {
    const key = `${keyName}${dirName}`;
    resolveAliasesObj[key] = path.resolve(__dirname, `../src/${dirName}`);
  });
  return resolveAliasesObj;
}
function diffDirAndFile(dirFilesArr = [], basePath = "") {
  const result = {
    dirs: [],
    files: [],
  };
  dirFilesArr.forEach((name) => {
    const currentFileStat = fs.statSync(
      path.resolve(__dirname, `${basePath}/${name}`)
    );
    const isDirectory = currentFileStat.isDirectory();
    if (isDirectory) result.dirs.push(name);
    else result.files.push(name);
  });
  return result;
}
module.exports = ({ keyName = "@" } = {}) => {
  return {
    //config:目前的一个配置对象
    config(config, env) {
      const resolveAliasesObj = getTotalSrcDir(keyName);
      //config函数可以返回一个对象,这个对象是部分得viteconfig配置(其实就是想要修改内容)
      return {
        resolve: {
          alias: resolveAliasesObj,
        },
      };
    },
  };
};
import { defineConfig } from "vite";
import MyViteAliases from "./plugins/ViteAliases";
export default defineConfig({
  plugins: [MyViteAliases()],
});

手写vite-plugin-html插件(简单实现)

module.exports = (options) => {
  return {
    //转换htmnl
    transformIndexHtml: {
      enforce: "pre", //插件提前执行
      transform: (html) => {
        return html.replace(/<%= title %>/g, options.inject.data.title);
      },
    },
  };
};
import { defineConfig } from "vite";
import MyCreateHtmlPlugin from "./plugins/CreateHtmlPlugin";
export default defineConfig({
  plugins: [MyCreateHtmlPlugin({
      inject: {
        data: {
          title: "主页123",
        },
      },
    })],
});

手写vite-plugin-mock插件(简单实现)

const fs = require("fs");
const path = require("path");
function getApis() {
  return new Promise((resolve, reject) => {
    fs.stat("mock", (err, result) => {
      if (err) {
        console.log(err);
        return;
      }
      if (result.isDirectory()) {
        resolve(require(path.resolve(process.cwd(), "mock/index.js")));
      }
    });
  });
}
module.exports = (options) => {
  return {
    configureServer(server) {
      //req:请求对象,res:响应对象,next:是否交给下一个中间件
      server.middlewares.use(async (req, res, next) => {
        const mockResult = await getApis();
        const mockItem = mockResult.find((item) => item.url === req.url);
        if (mockItem) {
          const responseData = mockItem.response(req);
          res.setHeader("Content-Type", "application/json");
          res.end(JSON.stringify(responseData));
        } else {
          next();
        }
      });
    },
  };
};

vite性能优化

  • 开发时的构建速度优化,vite是按需加载,所以我们不需要考虑。但在webpack中就需要我们去下功夫:cache-loader(如果两次构建源代码没有产生变化就使用缓存),thread-loader,开启多线程去构建...
  • 页面性能指标:
    • 首屏渲染时:fcp(first centent paint)
      • 懒加载:代码实现
      • http优化:强缓存与协商缓存
        • 强缓存:服务器给响应头追加有些字段(expires),客户端会记住这些字段,在expires(截至失效时间)之前,不会重新请求,而是从缓存里取
        • 协商缓存:是否使用缓存需与后端商量,当服务器打上协商缓存的标记以后,客户端在下次请求资源时会发送一个协商请求,服务端有变化就会响应该内容,没有变化就会响应304
    • 页面中最大元素的一个时长:lcp(largest content paint)
  • js逻辑、浏览器层面:
    • 在项目中,当我们使用某些方法时,最好的方式就是使用lodash库提供的,因为它的方法都是经过验证的最优的方法。如:防抖、节流等,一般我们自己写的或多或少是不如lodash的;再者当我们遍历数组时,当数据过多时,原生的foeEach就不如lodash的forEach方法。
    • 注意副作用的清除,我们知道组件是会频繁的挂载和卸载,如果当我们挂载一个组件时设置了一个setTimeout,如果我们在组件卸载时没有清除,组件再次挂载时会再开一个setTimeout,这就开启了两个线程
    • 写法上的注意事项:requestIdleCallback,requsetAnimationFrame,这需要我们对浏览器渲染原理有一定的认识
      • requestIdleCallback:传入一个函数,他会在浏览器的帧率空闲时去执行,浏览器的帧率说16.6ms去更新一次(执行js逻辑,重排,重绘...),做完一系列事情后有时间就会去执行。
const arr = []
//js取值是由近到远,下面的写法就不会频繁的去读取父级的
for(let i = 0,len = arr.lehgth;i < len; i++){}
  • css:
    • 关注继承属性:能继承的就不要重复书写
    • 尽量避免太过于深的css嵌套
  • 构建优化:vite(rollup) webpack
    • 体积优化:压缩,treeshaking,图片资源压缩,cdn加载,分包...

分包策略

分包就是把一些不会常规更新的文件进行单独打包处理

import { defineConfig } from "vite";
import path from "path";
export default defineConfig({
  build: {
    minify: false,
    rollupOptions: {
      input: {
        main: path.resolve(__dirname, "./src/main.ts"),
        product: path.resolve(__dirname, "./src/product.ts"),
      },
      output: {
        manualChunks: (id: string) => {
            //对node_modules进行分包
          if (id.includes("node_modules")) {
            return "vendor";
          }
        },
      },
    },
  },
});

gzip压缩

有时候我们的文件资源实在是太大了,就需要将所有的静态文件进行压缩,以达到减少体积的目的 gzip(vite-plugin-compression):当我们进行发布时会将.gz的文件一起给到运维,当浏览器请求的文件是有.gz时就返回.gz文件,浏览器收到响应结果发现响应头里有gzip对应字段就会进行解压得到原本的js文件(浏览器时要承担一定的解压时间),如果体积不是很大,不要使用gzip压缩。

动态导入

动态导入是es6的新特性

cdn加速

content delivery network:内容分发网络
将我们依赖的第三方模块全部写成cdn的形式,然后保证我们自己代码的一个小体积

import { defineConfig } from "vite";

import viteCDNPlugin from "vite-plugin-cdn-import";
export default defineConfig({
  plugins: [
    viteCDNPlugin({
      modules: [
        {
          name: "lodash",
          var: "_",
          path: "https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js",
        },
      ],
    }),
  ],
});

vite跨域

同源策略【仅在浏览器发生】:http交互默认情况下只能在同协议同域名同端口的两台终端进行

跨域:当A源浏览器的网页向B源的服务器地址(不满足同源策略,满足同源限制)请求对应信息就会产生跨域,跨域默认情况下会被浏览器拦截(跨域限制是服务器已经响应了内容,但是浏览器不会给你,不是服务器没有响应内容),除非对应的请求服务器标记这个A源是允许拿B以源的东西。

开发时态: 一般我们利用构建工具、脚手架、第三方库的proxy代理,或者自己搭一个开发服务器

生产时态:一般都不存在跨域问题,通常前端代码和后端服务都是放在一个域下面,也有跨域情况(跨部门)比如:百度百科:https://baike.baidu.com/ ,百度文库:https://wenku.baidu.com/ ,但是我们的用户信息:https://baidu.com/api/userInfo ,他们的总公司是同一个,用户信息也存放在总公司,部门到总公司就会产生跨域

  • ngnix:代理服务
  • 配置身份标记
    • Access-Control-Allow-Origin:表示服务器可以接受所有的请求源 image.png
import { defineConfig } from "vite";
export default defineConfig({
  //开发服务器中的配置
  server: {
    //配置跨域解决方案
    proxy: {
      //key + 描述对象 在遇到/api开头的请求时都会将其代理到target属性对应的域中去
      "/api": {
        target: "https://www.baidu.com",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});

Coze:AI应用快速开发的革新力量

作者 小Bk
2024年5月18日 20:53

在科技日新月异的今天,人工智能(AI)已渗透至我们生活的每个角落,从日常的语音助手到复杂的商业决策支持系统,AI正在以前所未有的速度改变着世界。然而,将这些前沿技术转化为实际应用往往需要跨越技术、设计、产品等多重领域的知识壁垒,这对于许多开发者而言无疑是一大挑战。正是在这个背景下,Coze应运而生,它作为一个全栈AI应用开发平台,致力于简化AI应用的开发流程,使得从前端到后端、从创意到产品,每一步都变得触手可及。

Coze:AI全栈工程师的终极舞台

Coze平台集成了前端与后端开发能力,并深度整合了AIGC技术,尤其是借助OpenAI等开源工具的力量,为开发者提供了一个全面、高效的开发环境。在这里,无论你是专注于JavaScript的前端高手,还是热衷于后端架构的工程师,甚至是探索AI内容生成的创新者,都能找到属于自己的舞台。

聊天与内容生成:AI互动的无限可能

Coze平台的聊天功能基于大型语言模型(LLM),不仅能够实现流畅的对话交互,还通过Completion技术进一步拓展了应用边界。例如,用户可以设定一个特定场景或情节,平台即可自动生成包含HTML元素的电影剧情概述,充分展现了prompt engineering的强大潜力。这种融合了创意与技术的交互模式,为内容创作者打开了全新的想象空间。

AI新闻应用:打造个性化信息宇宙

以“今日头条”为蓝本,Coze展示了AI如何重塑新闻体验。用户不仅能获得实时、精准的新闻推送,更能享受到由AI算法精心挑选的个性化内容。这背后的秘密在于Coze对AI应用的深刻理解和灵活运用,它使新闻机器人能够根据用户的需求即时调用头条新闻插件,提供定制化信息服务,从而实现了全新的AI应用体验。

AI新闻应用案例

  • 用户体验:以今日头条为例,说明如何通过AI提升新闻个性化和互动性。

  • 实现流程

    1. Prompt设计:定义人设与功能需求。
    2. 交互逻辑:模拟提问与回答的代码示例。
Javascript
解释
1// 假设的新闻机器人交互逻辑
2function requestNews(topic) {
3    const prompt = `获取关于${topic}的最新头条新闻摘要。`;
4    return call头条插件(prompt); // 假设的函数调用头条新闻API
5}
6
7const newsSummary = requestNews("人工智能");
8console.log(newsSummary);

image.png

Prompt设计:定制AI的思维与边界

Coze平台特别强调了prompt设计的重要性,允许开发者为AI应用设定详尽的人设和具体功能需求,同时也明确指出应用的限制所在。通过这种方式,开发者不仅能够赋予AI应用独特的性格和角色,还能确保其行为始终处于预设的框架之内,既安全又可靠。

低代码开发:简化应用构建流程

在Coze,低代码开发不再是个概念,而是实打实的生产力工具。通过拖拽式界面设计、一键属性设置和无缝数据绑定,即使是编程新手也能轻松搭建出功能完善的AI应用。这种直观、快捷的开发方式,大大缩短了从创意到产品的转化周期,让更多非技术背景的创意人士也能投身于AI应用的开发浪潮。

Coze的低代码开发优势

  • 拖拽界面:描述如何通过拖拽工具创建UI。
  • 属性设置与数据绑定:简述如何配置组件属性并绑定数据源。
Javascript
解释
1// 假设的低代码操作示意(非真实代码,仅为逻辑演示)
2dragAndDrop("新闻卡片").to("主界面");
3setWidgetProperty("新闻卡片", "data-source", "头条新闻API");
4bindData("新闻标题", "新闻卡片.title");

image.png

image.png

快速迭代与功能增强

Coze平台深知,持续的功能升级是AI应用保持竞争力的关键。因此,它提供了丰富的插件生态系统,允许开发者轻松添加新功能或优化现有服务,无论是增强自然语言处理能力、接入第三方服务,还是提升数据处理性能,都能通过简单的操作实现。同时,Coze的低代码特性确保了这些插件能够快速绑定到显示卡片上,让应用的每一次迭代都变得更加高效。

发布与市场推广

完成开发后,Coze为开发者提供了直接通往市场的快速通道。只需几个简单的步骤,你的AI应用就可以在诸如豆包这样的平台上架,触达成千上万的潜在用户。这一过程不仅简化了传统应用发布的繁琐流程,也为开发者带来了前所未有的市场推广机会。

加强AI应用的能力

  • 插件生态系统:介绍如何通过安装插件扩展功能。
  • NLP与LLM的集成:简述如何利用NLP进行更深入的语义理解。
Python
解释
1# 假设的NLP示例代码,用于情感分析
2from transformers import pipeline
3
4nlp = pipeline("sentiment-analysis")
5text = "我非常喜欢这个AI新闻应用!"
6print(nlp(text))

个人助手与未来趋势

在Coze的推动下,AI个人助手(如copilot)正逐渐成为未来应用的主要形态。这些助手不仅能够理解我们的语言,还能学习我们的习惯,预测我们的需求,真正成为我们日常生活和工作的得力伙伴。随着技术的不断进步和平台功能的持续拓展,Coze正引领我们步入一个由AI驱动的全新时代,一个应用开发更加民主化、个性化、高效化的时代。

结语

总之,Coze不仅是AI应用开发的加速器,更是连接创意与现实的桥梁。它以简化流程、强化功能、加速迭代为核心,为开发者提供了一条直达AI应用创新前沿的快车道。在这个平台上,每个人都有机会成为AI时代的塑造者,共同探索并定义未来科技的无限可能。

用 GoHugo 和 TailwindCSS 实现“活跃热力图”

作者 a9c93f23200
2024年5月18日 20:39

title = 'goHugo-Template 实现文章日期热力图' date = 2024-05-01 draft = false tags = ['前端', '组件开发', 'goHugo']

1 目标效果

举个例子,Github 中的热力图大家最熟悉了。我们根据热力图中不同方块颜色,可以了解作者在社区的活跃程度,是一种很好的可视化工具。

hugo-heatmap-2.png

在Hugo中,如果能可视化文章的更新活动,可以让读者对网站的更新动态有更具体的了解,因此我们看到热力图是比较有实用价值的,下面我们来看看我是如何实现该模块的。

2 获取总天数

想要绘制的方格子,首先我们计算需要多少个数据,写出 DaysMount_XMonthesBefore 方法如下,用以计算要显示的天数。

{{ define "partials/DaysMount_XMonthesBefore.html" }}
    {{ $x_mon_bef := int .x_mon_bef }}
    {{ $DaysMount := time.Now.Sub (time.Now.AddDate 0 $x_mon_bef 0)}}
    {{ return (div ($DaysMount).Hours 24) }}
{{ end }}

1.1 封装函数

使用 Hugo 提供的 partials 封装函数,该函数的作用是获取要显示的天数。

1.2 传入参数

$x_mon_bef := int .x_mon_bef 语句用于接收输入的参数,含义为“从前X个月的第1天开始计算到今天的天数”,如X为1,假设今天是5月2日,则获取的是4月1日到5月2日的总天数。

1.3 计算时间差

time.Now.AddDate 函数表示当前时间加上给定的量,Hugo文档对该方法的描述如下:

{{ $d := "2022-01-01" | time.AsTime }}
{{ $d.AddDate 0 0 1 | time.Format "2006-01-02" }} → 2022-01-02
{{ $d.AddDate 0 1 1 | time.Format "2006-01-02" }} → 2022-02-02
{{ $d.AddDate 1 1 1 | time.Format "2006-01-02" }} → 2023-02-02
{{ $d.AddDate -1 -1 -1 | time.Format "2006-01-02" }} → 2020-11-30

time.Now.Sub 函数计算当前时间和给定X个月之前的第一天之间的时间差,返回类型见如下:

{{ $t1 := time.AsTime "2023-01-27T23:44:58-08:00" }}
{{ $t2 := time.AsTime "2023-01-26T22:34:38-08:00" }}
{{ $t1.Sub $t2 }} → 25h10m20s

1.4 返回日期差

对于 25h10m20s 这种形式的返回,使用 div ($DaysMount).Hours 24 函数计算天数并返回变量。

2 绘制方格子

现在我们获得了总共有多少个方格子,下面我们依次把它们画出来。

{{ $x_mon_bef := -3 }}
{{ range seq 0 (partial "DaysMount_XMonthesBefore.html" (dict "x_mon_bef" $x_mon_bef)) }}

{{ end }}

{{ define "partials/DaysMount_XMonthesBefore.html" }}
    {{ $x_mon_bef := int .x_mon_bef }}
    {{ $DaysMount := time.Now.Sub (time.Now.AddDate 0 $x_mon_bef 0)}}
    {{ return (div ($DaysMount).Hours 24) }}
{{ end }}

2.1 先画出来所有格子再说

想到我们需要表格形式绘制方格,如果使用 table 会有大量的 trtd ,且我们是遍历的日期,日期之间的层级关系很难组织(画完这个 tr 中的 td 又要转到下一个 tr 中的 td ,之后再回到这个 tr 中的 td ,这是一件很麻烦的事),因此这里使用 grid 组织格子。

我在样式层使用了 TailwindCSS,这是一个很方便的库,把 CSS 样式组织到了类中,如果你要读懂我下面的代码,应该对 TailwindCSS 有些了解。

{{/*  .../input.css  # this is input file of tailwindcss  */}}
.card{
    @apply bg-white border shadow-md rounded-lg 
}


{{/*  .../partials/calendar.html  */}}
<div class="card w-[400px] h-80">
    <div class="square-month grid grid-rows-7 gap-1  p-3">
        {{ $x_mon_bef := -1 }}
        {{ range seq 0 (partial "DaysMount_XMonthesBefore.html" (dict "x_mon_bef" $x_mon_bef)) }}

        {{ end }}
    </div>
</div>

{{ define "partials/DaysMount_XMonthesBefore.html" }}
    {{ $x_mon_bef := int .x_mon_bef }}
    {{ $DaysMount := time.Now.Sub (time.Now.AddDate 0 $x_mon_bef 0)}}
    {{ return (div ($DaysMount).Hours 24) }}
{{ end }}

下面我们把方格子的渲染写入循环,每个循环渲染一个小方格子。

{{/*  .../input.css  # this is input file of tailwindcss  */}}
.card{
    @apply bg-white border shadow-md rounded-lg 
}
.square{
    @apply w-[16px] h-[16px] overflow-visible rounded-sm shadow-inner;
}
.square-level0{
    @apply bg-gray-200 shadow-gray-400/50  hover:shadow-gray-400/50
}

{{/*  .../partials/calendar.html  */}}
<div class="card w-[400px] h-80">
    <div class="square-month grid grid-rows-7 gap-1  p-3">
        {{ $x_mon_bef := -1 }}
        {{ range seq 0 (partial "DaysMount_XMonthesBefore.html" (dict "x_mon_bef" $x_mon_bef)) }}
        <div class="square square-level0 overflow-hidden">
            {{ . }}
        </div>
        {{ end }}
    </div>
</div>

{{ define "partials/DaysMount_XMonthesBefore.html" }}
    {{ $x_mon_bef := int .x_mon_bef }}
    {{ $DaysMount := time.Now.Sub (time.Now.AddDate 0 $x_mon_bef 0)}}
    {{ return (div ($DaysMount).Hours 24) }}
{{ end }}

我们可以看到代码运行的效果如下:

hugo-heatmap-3.png

2.2 设置格子的行和列

所有的格子都从第一行直接排到了最后一行,我们想把格子按照 grid 排列,即给定其 grid-rows-startgrid-column-start 值。

观察我们的目标效果图可以知道,我们需要 7 行,每一行都是相同的星期几,比如第一行都是星期日,第二行都是星期六;而每一列就是一周,从星期日、星期六到星期一,假设第一列是该月第一周,第二列是该月第二周。

我们假设每个月一号都是从星期日开始的,这样我们可以用得到的数字除以 7,余数相同的表示其在同一周,放在同一列,即用第几周表示 grid-column-start

同理,我们可以用星期几表示 grid-rows-start ,我使用了星期值(Weekday 转为 int 类型,见文档)表示 grid-rows-start ,下面我们开始行动。

我们首先获得格子对应的日期,我们只要获得最早的那个日期,之后的日期就在上面加格子代表的整数值即可。

<div class="card w-[400px] h-80">
    <div class="square-month grid grid-rows-7 gap-1  p-3">
        {{ $x_mon_bef := -1 }}
        {{ range seq 0 (partial "DaysMount_XMonthesBefore.html" (dict "x_mon_bef" $x_mon_bef)) }}
            {{ $first_day := (time.Now.AddDate 0 $x_mon_bef 0)}}
            {{ $row_start := (($first_day.AddDate 0 0 .).Weekday | int) }}
            {{ $col_start := div . 7 }}
            <div class="square square-level0 overflow-hidden"
            style="grid-row-start:  {{ add 1 $row_start }}; grid-column-start: {{ add 1 $col_start }}">
                {{ . }}
            </div>
        {{ end }}
    </div>
</div>

{{ define "partials/DaysMount_XMonthesBefore.html" }}
    {{ $x_mon_bef := int .x_mon_bef }}
    {{ $DaysMount := time.Now.Sub (time.Now.AddDate 0 $x_mon_bef 0)}}
    {{ return (div ($DaysMount).Hours 24) }}
{{ end }}

现在我们得到了如图所示的效果:

hugo-heatmap-4.png

2.3 修正错误

我们发现 0 - 5 显示都是正常的,这六个点是根据星期几算出来的行坐标,但 6 的列坐标提前了一个列,显示在了第一个列上,这是因为我们假设每个月一号都是从星期日开始的,用 div . 7 计算列必然会导致这一粗糙的结果,现在我们来修正这一点。

我们将 0 之前的格子用上一个月份的剩余几天填充,就像看日历一样,这个月的日历总会显示上个月的最后几天。

<div class="card w-[400px] h-80">
    <div class="square-month grid grid-rows-7 gap-1  p-3">
        {{ $x_mon_bef := -1 }}
        {{ $flag := 1 }}
        {{ range seq 0 (partial "DaysMount_XMonthesBefore.html" (dict "x_mon_bef" $x_mon_bef)) }}
            {{ $first_day := (time.Now.AddDate 0 $x_mon_bef 0)}}
            {{ $row_start := (($first_day.AddDate 0 0 .).Weekday | int) }}
            {{ $col_start := div . 7 }}
            {{ $date_before := $row_start }}
            {{ if (and (gt $date_before 0) $flag) }}
                {{ range seq 0 $date_before }}
                    <div class="square overflow-hidden" 
                    style="grid-row-start: {{ add 1 . }}; grid-column-start: 1">
                        {{ . }}
                    </div>
                {{ end }}
            {{ else }}
                <div class="square square-level0 overflow-hidden"
                style="grid-row-start:  {{ add 1 $row_start }}">
                    {{ . }}
                </div>
            {{ end }}
            {{ $flag = 0 }}
        {{ end }}
    </div>
</div>

{{ define "partials/DaysMount_XMonthesBefore.html" }}
    {{ $x_mon_bef := int .x_mon_bef }}
    {{ $DaysMount := time.Now.Sub (time.Now.AddDate 0 $x_mon_bef 0)}}
    {{ return (div ($DaysMount).Hours 24) }}
{{ end }}

我们首先要把19行的 grid-column-start 去掉,因为我们要用填充的格子占位置,剩下的格子从上往下,从左往右依次占位置即可。如果不去掉就会在这个位置产生格子冲突。

我们用 $flag 变量记录是否补过格子,如果补过了格子,那么 $flag 就置为 0,否则其为默认值 1.

我们设置 $date_before 变量,如果 $row_start 从一开始就大于0,说明其前几行一定需要用上一个月的数据补全, $data_before 为几就代表需要补全几个位置。

但是,这里 $row_start 值为 1,因此遍历 0 和 1 两个值,我们明明需要补一个位置,这里为什么遍历两次呢?原来这里要注意一下,我们借助第一次遍历补充格子,那么第一次遍历是不会输出原来的格子的,所以我们输出的灰色格子是少一个的,因此我们在补充格子的时候要多补充一个!

通过下方输出的效果图我们可以看到,白色小方格就是我们补充的格子,到现在为止,我们已经正确地输出了格子

hugo-heatmap-5.png

3 连接方格子和日期

现在我们输出 {{ . }} ,只能看到数字,下面我们要把数字和日期对应起来。我们将对应的值和 $firstday 相加就可以获得日期,现在我们把小格子的长度拉长一些,以方便看到日期的显示。

<div class="card w-[400px] h-80">
    <div class="square-month grid grid-rows-7 gap-1  p-3">
        {{ $x_mon_bef := -1 }}
        {{ $first_day := (time.Now.AddDate 0 $x_mon_bef 0)}}
        {{ $flag := 1 }}
        {{ range seq 0 (partial "DaysMount_XMonthesBefore.html" (dict "x_mon_bef" $x_mon_bef)) }}
            {{ $theDate := (($first_day.AddDate 0 0 .) | time.Format "2006-01-02") }}
            {{ $row_start := (($first_day.AddDate 0 0 .).Weekday | int) }}
            {{ $col_start := div . 7 }}
            {{ $date_before := $row_start }}
            {{ if (and (gt $date_before 0) $flag) }}
                {{ range seq 0 $date_before }}
                    {{ $theDate = ($first_day.AddDate 0 0 (int (sub . $date_before))) | time.Format "2006-01-02" }}
                        <div class="w-20 square-level0" 
                        style="grid-row-start: {{ add 1 . }}; grid-column-start: 1">
                            {{ $theDate }}
                        </div>
                {{ end }}
            {{ else }}
                <div class="w-20 square-level0" 
                style="grid-row-start:  {{ add 1 $row_start }}">
                    {{ $theDate }} 
                </div>
            {{ end }}
            {{ $flag = 0 }}
        {{ end }}
    </div>
</div>

{{ define "partials/DaysMount_XMonthesBefore.html" }}
    {{ $x_mon_bef := int .x_mon_bef }}
    {{ $DaysMount := time.Now.Sub (time.Now.AddDate 0 $x_mon_bef 0)}}
    {{ return (div ($DaysMount).Hours 24) }}
{{ end }}

显示日期的效果图如下,我们看到日期和数字已经完美得对应起来了。

hugo-heatmap-6.png

4 调整美化

现在我们把日期弄到悬浮窗上,当鼠标悬浮在小方格上的时候,悬浮窗出现并显示日期,鼠标移走时悬浮窗消失,这样会很美观。

同时,我们把日期增多一些,从 1 个月增加到 3 个月,这样显示的小方块更多一些,同时调整一下边框的大小,让它们看起来更顺眼。

{{/*  .../input.css  # this is input file of tailwindcss  */}}
.square-tip{
    @apply  w-32 h-fit p-2 -translate-x-16 -translate-y-11 bg-white shadow-lg  relative top-2 hidden text-xs text-center rounded-lg
}

.square:hover .square-tip{
    @apply block
}
{{/*  .../partials/calendar.html  */}}
<div class="card w-[300px] h-fit">
    <div class="square-month grid grid-rows-7 gap-1  p-3">
        {{ $x_mon_bef := -3 }}
        {{ $first_day := (time.Now.AddDate 0 $x_mon_bef 0)}}
        {{ $flag := 1 }}

        {{ range seq 0 (partial "DaysMount_XMonthesBefore.html" (dict "x_mon_bef" $x_mon_bef)) }}

            {{ $theDate := (($first_day.AddDate 0 0 .) | time.Format "2006-01-02") }}
            {{ $row_start := (($first_day.AddDate 0 0 .).Weekday | int) }}
            {{ $col_start := div . 7 }}
            {{ $date_before := $row_start }}
            {{ if (and (gt $date_before 0) $flag) }}
                {{ range seq 0 $date_before }}
                    {{ $theDate = ($first_day.AddDate 0 0 (int (sub . $date_before))) | time.Format "2006-01-02" }}
                    <div class="square overflow-hidden square-level0" 
                    style="grid-row-start: {{ add 1 . }}; grid-column-start: 1">
                        <div class="square-tip">
                            {{ $theDate }}
                        </div>
                    </div>
                {{ end }}
                
            {{ else }}
                
            <div class="square overflow-hidden square-level0" 
            style="grid-row-start:  {{ add 1 $row_start }}">
                <div class="square-tip">
                    {{ $theDate }}
                </div>
            </div>
            {{ end }}
            {{ $flag = 0 }}
        {{ end }}
    </div>
</div>


{{ define "partials/DaysMount_XMonthesBefore.html" }}
    {{ $x_mon_bef := int .x_mon_bef }}
    {{ $DaysMount := time.Now.Sub (time.Now.AddDate 0 $x_mon_bef 0)}}
    {{ return (div ($DaysMount).Hours 24) }}
{{ end }}

可以看到效果如下,我们已经做出了很好看的小方块日历。下面需要做的事情就是为它们加上颜色(热度),这样热度图就完成了。

hugo-heatmap-7.gif

5 获取每天的文章数量并用颜色渲染

我们设置 $count 变量用于统计次数,对于每个日期都遍历一次 .Site.RegularPages ,以获得该日期的 $count 值。注意由于 $theDay 变量不一样,我们需要在补全方格的时候重新计算一下要补全的日期的 $count

{{ $site := .Site.RegularPages }}

<div class="card w-[300px] h-fit">
    <div class="square-month grid grid-rows-7 gap-1  p-3">
        {{ $x_mon_bef := -3 }}
        {{ $first_day := (time.Now.AddDate 0 $x_mon_bef 0)}}
        {{ $flag := 1 }}

        {{ range seq 0 (partial "DaysMount_XMonthesBefore.html" (dict "x_mon_bef" $x_mon_bef)) }}
            {{ $theDate := (($first_day.AddDate 0 0 .) | time.Format "2006-01-02") }}
            {{ $row_start := (($first_day.AddDate 0 0 .).Weekday | int) }}
            {{ $col_start := div . 7 }}
            {{ $date_before := $row_start }}
            
            {{ $count := 0 }}
            {{ range $site }}
                {{ $ArticleDate := .Date | time.Format "2006-01-02" }}
                {{ if (eq $ArticleDate $theDate) }}
                    {{ $count = add $count 1 }}
                    {{ if gt $count 7 }}
                        {{ $count = 7 }}
                    {{ end }}
                {{ end }}
            {{ end }}
            
            {{ if (and (gt $date_before 0) $flag) }}
                {{ range seq 0 $date_before }}
                    {{ $theDate = ($first_day.AddDate 0 0 (int (sub . $date_before))) | time.Format "2006-01-02" }}
                    {{ $count := 0 }}
                    {{ range $site }}
                        {{ $ArticleDate := .Date | time.Format "2006-01-02" }}
                        {{ if (eq $ArticleDate $theDate) }}
                            {{ $count = add $count 1 }}
                            {{ if gt $count 7 }}
                                {{ $count = 7 }}
                            {{ end }}
                        {{ end }}
                    {{ end }}
                    <div class="square overflow-hidden  square-level{{ $count }}" 
                    style="grid-row-start: {{ add 1 . }}; grid-column-start: 1">
                        <div class="square-tip">
                            {{ $count }}, {{ $theDate }}
                        </div>
                    </div>
                {{ end }}
                
            {{ else }}
                
            <div class="square overflow-hidden  square-level{{ $count }}" 
            style="grid-row-start:  {{ add 1 $row_start }}">
                <div class="square-tip">
                    {{ $count }}, {{ $theDate }}
                </div>
            </div>
            {{ end }}
            {{ $flag = 0 }}
        {{ end }}
    </div>
</div>


{{ define "partials/DaysMount_XMonthesBefore.html" }}
    {{ $x_mon_bef := int .x_mon_bef }}
    {{ $DaysMount := time.Now.Sub (time.Now.AddDate 0 $x_mon_bef 0)}}
    {{ return (div ($DaysMount).Hours 24) }}
{{ end }}

我们增加 css 样式,对于 level0 - level7 每个等级的方格颜色由浅入深颜色不同。

.square-level1{
    @apply bg-green-300 shadow-green-500/50  hover:shadow-green-900/50
}

.square-level2{
    @apply bg-green-400 shadow-green-600/50  hover:shadow-green-900/50
}

.square-level3{
    @apply bg-green-500 shadow-green-700/50  hover:shadow-green-900/50
}

.square-level4{
    @apply bg-green-600 shadow-green-800/50  hover:shadow-green-900/50
}

.square-level5{
    @apply bg-orange-600 shadow-orange-800/50  hover:shadow-orange-900/50
}

.square-level6{
    @apply bg-red-500 shadow-red-700/50  hover:shadow-red-900/50
}

.square-level7{
    @apply bg-red-600 shadow-black/50  hover:shadow-red-900/50
}

.square-level0{
    @apply bg-gray-200 shadow-gray-400/50  hover:shadow-gray-400/50
}

.square{
    @apply w-[16px] h-[16px] overflow-visible rounded-sm shadow-inner;
}

.square-tip{
    @apply  w-32 h-fit p-2 -translate-x-16 -translate-y-11 bg-white shadow-lg  relative top-2 hidden text-xs text-center rounded-lg
}

.square:hover .square-tip{
    @apply block
}

hugo-heatmap-8.gif

6 留以思考

如果你已经读完了这篇文章,你可以试试解决下面两个问题:当 $count 大于 7 的时候我们应该怎么处理?$count 的计算是否可以封装成函数,如何封装?

鸿蒙开发基础 - 首选项持久化数据使用及封装

作者 持青伞
2024年5月18日 20:30

1. 什么是数据持久化?数据持久化有什么用?

在日常的开发中,我们可能遇到需要持久的记录某些数据。确保在应用被关闭后,数据依然可以保存下来,而不是每次打开都被初始化
那么,什么是数据持久化呢?简单的来讲,就是把数据保存到磁盘空间,而非是内存中(内存中的数据会在进程销毁后丢失)。
数据持久化有什么用呢?打开比方,我们开发了一个小说阅读的软件。在设置中用户可以设置阅读时的字体颜色,大小,粗细,行高等属性。当用户设置完后,可以用数据持久化来保存这些参数,避免了用户每次打开应用都要重新设置这些一遍参数

cac7e721e12eeac6f20714cd30377876.jpeg

2. 在鸿蒙开发中,如何使用数据持久化?

其实鸿蒙开发中,给我们提供了非常多的数据持久化方案。这里简单列举几个:

  • 应用级变量的状态管理:PersistentStorage
  • 用户首选项:preferences
  • fs文件操作模块
  • 轻量级关系型数据库(RDB):relationalstore
  • ...

在这里,主要介绍用户首选项 preferences 来做数据持久化

2e59fed0ba3d555346793840ec38671a.jpeg

首先,让我们来看看官网对preferences的介绍:

用户首选项为应用提供Key-Value键值型的数据处理能力,支持应用持久化轻量级数据,并对其修改和查询。

数据存储形式为键值对,键的类型为字符串型,值的存储数据类型包括数字型、字符型、布尔型以及这3种类型的数组类型。

2.1 在使用前,需要我们进行导包

注意,这里可以从两个路径导入preferences。这两个没有区别,都能用,但是更推荐从@kit.ArkData进行导入,因为@kit.ArkData@ohos.data.preferences新,@ohos.data.preferences后续版本可能会废弃

import { preferences } from '@kit.ArkData'
import preferences from '@ohos.data.preferences';

bbb6fc1ef79bebe7429980c1cf3bddf6.jpeg

2.2 实例化首选项

使用preferences.getPreferencesSync(context: Context, options: Options): Preferences以同步的方法来拿到首选项的实例
这里需要两个实参,一个是上下文对象,也就是getContext(),第二个参数是一个对象,对象内需要一个name(key)属性,且值只能为string类型。name可以自行命名
注意:这个name是首选项数据写入磁盘后文件的名字,后面也会根据这个name去读取文件里面保存的数据

const pre = preferences.getPreferencesSync(getContext(), { name: "name" })

2.3 使用增/改、删、查三个方法

在我们拿到的pre对象上有一系列方法,具体可以查阅 官方文档
这里主要使用到4个方法来实现数据的增/改、查、删操作,分别是putSync(增/改的同步方法),getSync(查的同步方法),deleteSync(删的同步方法),flush(写入磁盘的方法,此异步操作) 注意:preferences只支持写入最大8kb的数据,超过8kb的数据会导致写入失败!!!
pre.flush()是把结果写入磁盘的方法,为异步操作。在操作完数据之后,把结果写入磁盘,才能完成数据的持久化!!!

下面展示这三个方法的用法:

pre.putSync(key, value)
// 把内存中的数据写入磁盘,完成持久化
pre.flush()
pre.getSync(key, defaultvalue)
pre.deleteSync(key)
// 删除key对应的值,并且把结果写入磁盘
pre.flush()

key:需要保存数据的键名
value:需要保存数据的值。有类型限制,只支持保存number | string | boolean | Array<number> | Array<string> | Array<boolean> | Uint8Array 如果需要保存复杂类型(对象等),需要使用JSON.stringify()方法转化为JSON字符串
defaultvalue:默认的返回值,如果值为null或者非默认值类型,返回默认数据defaultvalue。同样有类型限制,参考value

2.4 如何才看首选项数据有没有被写入磁盘

当我们对数据完成操作之后,对数据是否被持久化可能存在疑问。其实我们可以进入应用的文件目录来查看保存的文件数据
当我们保存完数据(使用手机模拟器)之后,在 Dev Eco Studio 中找到 Device File Browser,打开文件目录窗口

image.png

这里的路径为:/data/app/el2/100/base/com.example.myharmony/haps/entry/preferences image.png

如果preferences文件夹内没有文件,可以同步刷新一下

image.png

3. 对于首选项持久化的类封装

因为首选项的持久化的操作步骤过于繁琐,且时常会用到.所以毫无疑问,把这些代码封装成一个工具类可以更方便我们的使用
这里直接提供封装好的首选项持久化类,可以在各个不同的项目中通用:

import { preferences } from '@kit.ArkData'
// import preferences from '@ohos.data.preferences'

class PreferencesManager {
  // 需要保存到磁盘的文件名称
  private pname: string

  constructor(pname: string) {
    this.pname = pname
  }

  // 泛型继承, 规定传入的数据类型. 保存数据方法, 负责保存数据到首选项
  // 规定类型为 number | string | boolean | Array<number> | Array<string> | Array<boolean> | Uint8Array;
  async saveData<T extends preferences.ValueType>(key: string, value: T) {
    const pre = preferences.getPreferencesSync(getContext(), { name: this.pname })
    pre.putSync(key, value)
    // 把内存中的数据写入磁盘,完成持久化
    await pre.flush()
  }

  // 获取数据
  // 注意, 此处使用读取数据的同步方法, 没有使用flush写入磁盘, 所以不需要加async, 加上async后会导致方法异步执行
  getData<T extends preferences.ValueType>(key: string, defaultvalue: T) {
    const pre = preferences.getPreferencesSync(getContext(), { name: this.pname })
    return pre.getSync(key, defaultvalue) as T
  }

  // 删除数据
  async delAllData(key: string) {
    const pre = preferences.getPreferencesSync(getContext(), { name: this.pname })
    pre.deleteSync(key)
    await pre.flush()
  }
}

// 实例化后导出, MyPreferences和Store可自行按需求命名
export const MyPreferences = new PreferencesManager("Store")

基于Docker快速搭建WordPress站点

作者 照月白lz
2024年5月18日 20:07

大家好!我是照月白lz,一名一线码农,闲来待业在家,今天来写点“老掉牙”的东西 — WordPress,就当做篇学习笔记吧。

记得之前在开发群里,某天有人求助了一下WordPress相关的问题,当时回答的人都说不会,我当时潜水时还在想着:"WordPress?这么老掉牙的东西还有人在用?开发......",自行脑补(¬_¬)

或许“好风凭借力,送我上青天”的本意是积极向上的,但也能从另外一个侧面完美的诠释了时代的眼泪。情况好时,认知里好像都在争论着XX技术怎么样怎么样,到最后码农其实也和这些并没有什么两样,真讽刺。所以闲下来后一直在学习、思考,努力提升自己的认知、方法,与诸君共勉。

这篇笔记源于宁皓大佬的:《WordPress:一小时搭建商业作品展示网站》,讲的非常详细,推荐大家去学习。对他的一句话感觉触动很深,在此记录一下:“不同的实现方法的区别就在于会给你不同的感受,解决问题的思维方式也会有不同。 比如用WordPress来实现,当遇到一个需求的时候,第一感觉就是找一个插件来解决。而基于应用框架来开发,会有不同的思维方式。能快速实现就是王道,无论用什么方法实现,WordPress也好,低代码也罢,或者基于框架从头开发,都没那么重要。我们发现一个能用软件应用解决的问题,然后找方法快速实现,验证是否能解决这个问题,用户是否在乎?”

闲话少叙,正式开始吧

运行 WordPress 通常需要安装LAMP(Linux、Apache、MySQL 和 PHP)或LEMP(Linux、Nginx、MySQL 和 PHP)堆栈,要做到这些可能非常耗时,同时也要付出较多的学习成本。本篇我们通过使用Docker来简化设置和安装 WordPress 的过程。本篇的容器将包括 MySQL 数据库和 WordPress 本身,比较简单;如果想进一步拓展,可以在容器中再加入Nginx Web 能力,有动手能力的同学可以研究一下。

安装环境

首先,需要在本地开发环境上安装docker环境和dockerDesktop工具,在需要部署的云服务器上安装docker环境和Nginx。

因为,你需要先在本地环境编写代码、docker运行、初始化配置,然后将项目代码推送到云服务器, 通过docker运行,最后为容器的运行端口配置nigix。这里的nginx,我个人对Nginx Proxy Manager使用的比较熟练,所有使用了这个。大家可以根据自己的需求自行取舍替换。以下是工具和环境的官方文档:

编写Docker Compose定义服务

首先,新建一个项目文件夹,如:my-wordpress;在项目文件夹my-wordpress下创建文件:docker-compose.yml

接着,我们开始编辑docker-compose.yml文件,先定义wordpress服务,如下代码:

version: "3.3"
services:
  wordpress:
    image: wordpress:latest
    restart: always
    # 将主机的8081端口映射到容器的80端口,你可以将8081改成别的端口,但需要注意其它地方的配置
    ports:
      - 8081:80
    # 定义环境变量,数据库用户名、密码...
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: "wordpress"
      WORDPRESS_DB_PASSWORD: "wordpress_pass"
      WORDPRESS_DB_NAME: "wordpress"
    # 定义数据卷,映射到当前项目目录的/app/wp-content文件夹下
    # 后期方便我们拓展自定义一些wordpress配置
    volumes:
      - ./app/wp-content:/var/www/html/wp-content/

再定义db数据库服务,如下代码:

db:
    image: mysql:5.7
    restart: always
    environment:
      MYSQL_DATABASE: "wordpress"
      MYSQL_USER: "wordpress"
      MYSQL_PASSWORD: "wordpress_pass"
      MYSQL_ROOT_PASSWORD: "wordpress_root_pass"
    # 定义数据卷,映射到当前项目目录的/app/db文件夹下
    # 后期方便我们对数据进行迁移操作
    volumes:
      - ./app/db:/var/lib/mysql

然后,最重要的一步,我们需要定义wordpress服务依赖于db服务

# ...
services:
  wordpress:
    # ...
    # 注意:这里写db,是因为数据库服务名定义的是:db ↓↓↓↓
    depends_on:
      - db
    ports:
    # ...

最后,我们还需要修改环境变量值,为db数据库设置密码和账号:你可以将配置中的“wordpress”替换为你的用户名,将"wordpress_pass"、“wordpress_pass”分别替换为你的密码, 这些配置仅在容器内部使用。注意:wordpress和db服务的环境变量值需要一一对应的上,否则容器会报错,从而导致数据库连接失败,从而WordPress也启动不了,我在这里遇到过坑。

在linux、macos系统中,你可以使用命令openssl rand -base64 32来生成随机字符串,摘取复制其中的片段作为密码。

至此,初始化完整的docker-compose.yml内容如下:

version: "3"
services:
  wordpress:
    image: wordpress:latest
    restart: always
    depends_on:
      - db
    ports:
      - 8081:80
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: "wordpress"
      WORDPRESS_DB_PASSWORD: "wordpress_pass"
      WORDPRESS_DB_NAME: "wordpress"
    volumes:
      - ./app/wp-content:/var/www/html/wp-content/
      - ./app/config/php-uploads.ini:/usr/local/etc/php/conf.d/uploads.ini

  db:
    image: mysql:5.7
    restart: always
    environment:
      MYSQL_DATABASE: "wordpress"
      MYSQL_USER: "wordpress"
      MYSQL_PASSWORD: "wordpress_pass"
      MYSQL_ROOT_PASSWORD: "wordpress_root_pass"
    volumes:
      - ./app/db:/var/lib/mysql

在项目文件夹下,终端使用命令docker compose up -d 来启动容器;首次会先下载image镜像,可能稍慢,镜像下载完成后才能运行容器,这些都是docker自动完成的。

通过页面完成安装

使用docker ps命令或者Docker Desktop工具检查容器运行状态,查看容器日志没有报错后,浏览器打开http://localhost:8081/,页面会自动引导你选择语言、创建站点名称、管理员账号密码,如下:

Screenshot-1.png

Screenshot-2.png

安装完成后,使用你设置的用户名密码登录后,即可进入仪表盘页面:

Screenshot-6.png

至此,WordPress项目的基础搭建工作已经完成,你可以愉快的在本地玩耍了

配置媒体最大上传限制

话说平常买东西都讲究一个“便宜、量大、管饱”,那么WordPress的默认配置:媒体文件上传大小限制2M,如何能够满足我们的需求,试想一下我们编辑页面用到的高清图、视频文件体积一般都得超过2M吧?小水管是万万不够用的,大腚得配大裤衩(¬‿¬)

Screenshot-3.png

首先,停止当前容器:docker compose down

我们需要在项目 /app 目录下新建文件夹: /config,在config文件夹下创建:php-uploads.ini文件。

编辑php-uploads.ini文件,写入配置并保存:

file-uploads = On
upload_max_filesize = 200M
post_max_size = 200M
max_execution_time = 600

然后,在项目根目录下的docker-compose.yml文件中新增config数据卷配置:

# ...
services:
  wordpress:
    # ...
    volumes:
      - ./app/wp-content:/var/www/html/wp-content/
      # 新增上传配置的数据卷 ↓↓↓↓
      - ./app/config/php-uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
# ...

最后重新启动docker容器:docker compose up -d,此时媒体的最大上传大小限制就改为了200M

部署到云服务器

部署到云服务器,就是把本地项目代码扔到线上服务器的某个文件夹,然后使用docker跑起来?确实是这个道理,而更优雅方便的管理方式是:创建一个代码仓库,把本地代码推到仓库,然后云端服务器pull代码,然后再通过云端的docker环境把项目跑起来。

细说起这些运维相关的技术就老长老长了,互联网资源很丰富,这里偷懒(。-ω-)zzz,就不详细描述了。你的云端服务器需要满足以下条件:

  • docker 环境
  • git 环境
  • nginx 环境
  • 一个解析到当前云服务器的域名,如:xxx.com
  • 将项目代码拉取到云服务器

再唠一下,域名的ssl证书其实也可以不需要, 但是http协议网站在处于4202年的浏览器下还是有一些些限制的。而且云服务厂商都提供了免费的证书,就当学习一些新知识了。最新的腾讯云免费证书的有效期好像由12个月缩短成3个月了( ̄へ ̄)

然后,在项目文件夹下执行命令:docker compose up -d,启动WordPress容器,检查容器状态、日志。

最后呢,就是设置nginx代理了,代理设置完成后你就可以通过域名访问WordPress站点然后设置站点信息了(≧∇≦)/

总结

本篇笔记旨在记录如何快速的搭建一个WordPress站点, 也许在后续场景用到时,通过查看本篇笔记能够快速搭建一个成品网站。可能是我的wordpress镜像版本是最新的,又或者使用的云服务器操作系统是ubuntu?我并没有遇到过像宁皓大佬视频里的设置云服务器wp-content文件夹的权限问题,我的媒体文件直接就上传创建成功了。再次感谢大佬的视频,有很详实的搭建过程步骤和主题的用法,零基础小白也能轻松上手!

点击媒体文件上传,wp-content文件夹的权限问题报错如下:

Screenshot-5.png

其它资源

WordPress:一小时搭建商业作品展示网站
彻底搞懂反向代理神器Nginx Proxy Manager
喵容网-WordPress主题
本篇项目源码

鸿蒙布局元素篇(二)-弹性布局(Flex)

作者 小刘同学
2024年5月18日 19:07

Flex布局元素有些功能和线性布局Row容器/Column容器是重合或者说包含他们的功能,适合更复杂的一些场景。

Flex(value?: { direction?: FlexDirection, wrap?: FlexWrap, justifyContent?: FlexAlign, alignItems?: ItemAlign, alignContent?: FlexAlign }):和前端的flex布局定义基本一致。只用法传承参的方式,不是属性的方式。

参数 参数类型 描述
direction FlexDirection Row(默认)/RowReverse/Column/ColumnReverse 主轴的方向。
wrap FlexWrap NoWrap(默认)/Wrap/WrapReverse 是否允许换行,WrapReverse反向排列并允许换行
justifyContent FlexAlign Start(默认)/Center/End/SpaceBetween/SpaceAround/SpaceEvenly 主轴上的对齐格式。
alignItems ItemAlign Auto/Start(默认)/Center/End/Stretch/Baseline 交叉轴上的对齐格式。
alignContent FlexWrap Start(默认)/Center/End/SpaceBetween/SpaceAround/SpaceEvenly 交叉轴中有额外的空间时,多行内容的对齐方式。仅在wrap为Wrap或WrapReverse下生效。

direction

  • 值为Row或RowReverse, 理解为线性布局中的Row和反向Row,后面说的主轴就是水平方向,交叉轴就是垂直方向。
  • 值为Column或ColumnReverse,理解为线性布局中的Column和反向Column,后面说的主轴就是垂直方向,交叉轴就是水平方向。

和线性布局相同的内容就不列举了,以下列举一些换行相关已经Flex特别的一些参数

@Entry
@Component
struct Index4 {
  @Provide  message: string = 'Hello World'

  build() {
    Column({space: 10}){
      // 设定水平是主轴方向;反向排列并允许换行;在交叉轴元素之间的距离相同且与两边距离相同
      Flex({ direction: FlexDirection.Row,wrap: FlexWrap.WrapReverse,
        alignContent: FlexAlign.SpaceEvenly}) {
        Text('1').width('20%').height(50).backgroundColor(0xF5DEB3)
        Text('2').width('20%').height(50).backgroundColor(0xD2B48C)
        Text('3').width('20%').height(50).backgroundColor(0xF5DEB3)
        Text('4').width('20%').height(50).backgroundColor(0xD2B48C)
        Text('5').width('20%').height(50).backgroundColor(0xF5DEB3)
        Text('6').width('20%').height(50).backgroundColor(0xD2B48C)
        Text('7').width('20%').height(50).backgroundColor(0xF5DEB3)
      }
      .height(200)
      .padding(10)
      .backgroundColor(0xAFEEEE)

      // 设定水平是纵向方向;允许换行;在交叉轴两边对齐且两元素之间的距离相同。
      Flex({ direction: FlexDirection.Column,wrap: FlexWrap.Wrap,
        alignContent: FlexAlign.SpaceBetween
      }) {
        Text('1').width('20%').height(50).backgroundColor(0xF5DEB3)
        Text('2').width('20%').height(50).backgroundColor(0xD2B48C)
        Text('3').width('20%').height(50).backgroundColor(0xF5DEB3)
        Text('4').width('20%').height(50).backgroundColor(0xF5DEB3)
        Text('5').width('20%').height(50).backgroundColor(0xF5DEB3)
      }
      .width("100%")
      .height(200)
      .padding(10)
      .backgroundColor(0xAFEEEE)
      // 设定纵向是主轴方向;Stretch让子元素在交叉轴铺满;交叉轴元素之间的距离相同,到两边的距离是元素之间距离的一半
      Flex({ direction: FlexDirection.Column,alignItems: ItemAlign.Stretch,
        justifyContent: FlexAlign.SpaceAround
      }) {
        Text('1').width('20%').height(50).backgroundColor(0xF5DEB3)
        Text('2').width('20%').height(50).backgroundColor(0xD2B48C)
        Text('3').width('20%').height(50).backgroundColor(0xF5DEB3)
      }

      .height(200)
      .width('100%')
      .padding(10)
      .backgroundColor(0xAFEEEE)
    }
  }
}

image.png

总结

属于是扩展版的线性布局,允许换行,以及扩充了交叉轴多行的对齐管理。

💥【sheetjs】纯前端如何实现Excel导出下载和上传解析?

2024年5月18日 18:45

最近忙着做项目,Rust精华小册忙完项目就更新😁。近期文章总结一下项目中遇到的一些好玩的点。

本文介绍的是sheetjs下面的xlsx库, 它有付费版和开源版。付费版可以为表格设置好看的样式,开源版则没有这些功能。

Github仓库停留在两年前的版本了,最新的版本是自托管的,仓库地址如下: https://git.sheetjs.com/sheetjs/sheetjs

首先,我们基于vite创建一个react的项目,然后将xlsx安装到我们的项目:

npm i --save https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz

整个项目用到了vite、react、sheetjs、arco-design(字节的ReactUI框架,类似antd)。

前端实现Excel导出下载

先说一下需求,我们从后端取到的json数据,经由前端处理成Excel的二进制格式,然后点击Button完成下载。

20240518113033_rec_.gif

这个需求比较简单,但要我们从零实现,还是会费一些功夫。好在有xlsx帮我们简化这个实现过程。

import {read, utils, writeFile} from "xlsx";

<button onClick={() => {
// 假设我们从后端获取到的json如下
const json = [
{name: "George Washington", birthday: "1732-02-22"},
{name: "John Adams", birthday: "1735-10-19"},
];
// 构造sheet
const worksheet = utils.json_to_sheet(json);
// 构造workbook
const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, "Dates");
        // 默认使用json结构中的name和birthday作为表头,也可以使用下面代码自定义表头
        utils.sheet_add_aoa(worksheet, [['ID', '指标名称']], { origin: 'A1' });

// 下载文件
writeFile(workbook, "Presidents.xlsx", {compression: true});
}}>
Download Excel
</button>

总结上面代码的步骤:

  1. 从后端获取json数据
  2. 将json构造为worksheet,并起一个名字叫做Dates。如图,可以将worksheet理解为一个Tab页。

将json构造为worksheet

  1. 将worksheet放到workbook中,一个workbook就是一个excel文件了。
  2. 最后一步就是下载Excel。

前端实现Excel上传解析

上传的需求是,点击上传按钮选择文件,然后通过xlsx这个库解析成json,整个都是过程是在浏览器中进行的。最后将json传给后端即可。示意图如下:

20240518113324_rec_.gif

同样,我们这里用到的解析库也是sheetjs提供的xlsx库。

import {read, utils, writeFile} from "xlsx";
import {Message, Upload} from "@arco-design/web-react";
import '@arco-design/web-react/dist/css/arco.min.css'

<Upload
multiple
action='/'
beforeUpload={async (file) => {
                // 将file对象转换为一个arrayBuffer
const fileBuffer = await file.arrayBuffer()
                // 使用read函数解析为workbook对象
const workbook = read(fileBuffer)

console.log(workbook.SheetNames)
console.log(workbook.Sheets)
                // 获取到第一个worksheet
const first_sheet = workbook.Sheets[workbook.SheetNames[0]];
                // 将worksheet中的数据转换为json结构的数据
const json = utils.sheet_to_json(first_sheet)
Message.success(JSON.stringify(json))
return Promise.reject();
}}
/>

总结一下上面代码的步骤:

  1. 想办法获取到File对象,可以用input标签设为file类型,也可以像我这样使用UI提供的Upload组件。
  2. 将file对象转换为一个ArrayBuffer
  3. 使用read函数解析为workbook对象
  4. 获取到第一个worksheet
  5. 将worksheet中的数据转换为json结构的数据

详细的源码可以查看代码仓库:https://github.com/fullee/sheetjs-demo

好啦,如果文章对您有帮助,欢迎点赞收藏加关注,点赞越多更新越快,哈哈。下篇文章计划是总结一下项目中用到的React状态库zustand。

警惕来自Timitator组织RUST特马的攻击

2024年5月16日 19:39

概述

Timitator(战术模仿者) 组织自2022年到2023年针对我国的能源、高校、科研机构及军工等行业进行攻击,主要采取鱼叉、nday等方式进行打点。

其鱼叉攻击分别投递过exe、chm、iso(img)及lnk等格式的载荷,在受害者成功执行该恶意附件后,在第一阶段时其会加载cobaltstrike并建立稳定连接,在第二阶段通过coba

【iOS逆向与安全】iOS远程大师:通过H5后台远程查看和协助iPhone设备

2024年5月16日 12:54

前言

在移动设备测试和远程协助的过程中,能够远程查看和协助iPhone设备是一项非常实用的功能。为了解决这一需求,我开发了一款名为iOS远程大师的产品,允许用户通过H5后台界面查看和协助越狱或非越狱的iPhone设备。本文将详细介绍iOS远程大师的开发过程和技术实现。


一、技术实现

整个项目的核心技术包括H5前端界面、WebSocket通信、服务器转发和iPhone设备处理。下面将分步骤介绍每个部分的实现。

1. H5前端界面

在H5前端,我使用了vue框架来构建用户界面。用户通过这个界面可以实时查看iPhone的屏幕,并通过鼠标发送交互指令。

  • 实时屏幕显示:通过WebSocket连接,接收服务器转发的iPhone屏幕图像,并在H5页面上进行渲染。
  • 用户指令捕获:在div中监听用户的鼠标事件,并将这些事件转换为特定的指令格式。

横屏坐标转换

在前端实现中,处理横屏模式下的坐标转换至关重要。在 H5 后台将设备切换为横屏时,尽管你看到的界面已经是横屏了,但 iPhone 端实际仍然是竖屏,因此必须进行坐标转换,以确保点击事件能够在正确的位置被执行,并且保持显示的一致性。

const convertCoordinates = (width, height, clickX, clickY) => {
  const angle = rotateAngle.value % 360
  if (angle === -90) {
    const rotatedX = height - clickX * Math.cos(angle * Math.PI / 180) + clickY * Math.sin(angle * Math.PI / 180)
    const rotatedY = -clickX * Math.sin(angle * Math.PI / 180) + clickY * Math.cos(angle * Math.PI / 180)
    clickX = rotatedX
    clickY = rotatedY
  } else if (angle === -180) {
    const rotatedX = width - clickX
    const rotatedY = height - clickY
    clickX = rotatedX
    clickY = rotatedY
  } else if (angle === -270) {
    const rotatedX = clickY
    const rotatedY = width - clickX
    clickX = rotatedX
    clickY = rotatedY
  }
  return { clickX, clickY }
}

2. WebSocket通信

前端和设备之间采用WebSocket进行实时通信,以确保低延迟的指令传输。

image-20240515180325536

  • 建立连接:前端与后端建立WebSocket连接,确保双向通信的实现。
  • 指令发送:用户在前端页面上进行交互操作,前端将这些指令发送给后端,通过WebSocket进行传输。
  • 指令转发:后端接收到前端发送的指令后,根据指令内容识别目标设备,并将指令下发到对应的设备。
  • 执行指令:目标设备接收到后端发送的指令,并执行相应的操作。
  • 结果回传:设备执行完指令后,将执行结果发送给后端,后端再通过WebSocket将结果回传给前端,以供用户查看或处理。

3. iPhone设备处理命令

在iPhone设备端,我们区分越狱和不越狱设备的支持情况。

  • 越狱设备:支持iOS 12到14版本,通过私有API实现更深层次的操作。主要包括硬件触发和屏幕点击。

    • 按键操作:通过私有API直接触发iPhone的硬件功能,例如电源、Home键、音量+-等操作。
    • 屏幕点击:利用越狱权限,直接在屏幕坐标上模拟触摸事件。

    按键操作的部分代码如下:

    + (void)sendHIDEventWithUsagePage:(uint16_t)usagePage usage:(uint16_t)usage down:(Boolean)down {
        uint64_t abTime = mach_absolute_time();
        IOHIDEventRef event = IOHIDEventCreateKeyboardEvent(kCFAllocatorDefault, abTime, usagePage, usage, down, 0);
        IOHIDEventSetIntegerValue(event,4, 1);
        IOHIDEventSetSenderID(event, senderID);
        postIOHIDEvent(event);
    }
    ​
    static void postIOHIDEvent(IOHIDEventRef event)
    {
        static IOHIDEventSystemClientRef ioSystemClient = NULL;
        if (!ioSystemClient){
            ioSystemClient = IOHIDEventSystemClientCreate(kCFAllocatorDefault);
        }
        if (senderID != 0) {
            IOHIDEventSetSenderID(event, senderID);
        } else {
            return;
        }
        IOHIDEventSystemClientDispatchEvent(ioSystemClient, event);
    }
    

    具体的按键代码可参考:https://iphonedev.wiki/IOHIDFamily

    屏幕点击的部分代码如下:

    // 示例代码:使用私有API触发屏幕点击
    void simulateTouch(CGFloat x, CGFloat y) {
        // 在实现模拟滑动过程中,滑动效果始终不理想,最终放弃。引用IOS13-SimulateTouch 项目来实现了屏幕的点击和滑动
        // 具体实现参考 IOS13-SimulateTouch 项目
        // https://github.com/xuan32546/IOS13-SimulateTouch
    }
    

    在这也要感谢微信群里的 @福州-啊嘴 在模拟滑动中提供的帮助

  • 非越狱设备:支持iOS 15及以上版本,利用XCUITest实现硬件的触发和屏幕的点击。

    • 硬件触发:通过XCUITest框架模拟按键操作。
    • 屏幕点击:使用XCUITest框架在指定坐标模拟点击。

    按键操作的部分代码如下:

    + (void)sendHIDEventWithUsagePage:(uint16_t)usagePage usage:(uint16_t)usage down:(Boolean)down {
      if (usage == 0xe9) {
        [[XCUIDevice sharedDevice] pressButton:XCUIDeviceButtonVolumeUp];
      } else if (usage == 0xea) {
        [[XCUIDevice sharedDevice] pressButton:XCUIDeviceButtonVolumeDown];
      } else if (usage == 0x30 && down) {
        if ([XCUIDevice sharedDevice].fb_isScreenLocked) {
          [[XCUIDevice sharedDevice] fb_unlockScreen:nil];
        } else {
          [[XCUIDevice sharedDevice] fb_lockScreen:nil];
        }
      }
    }
    ​
    + (void)simulateHome {
      [[XCUIDevice sharedDevice] pressButton:XCUIDeviceButtonHome];
    }
    

    屏幕点击的部分代码如下:

    @interface XCUICoordinate ()
    ​
    // 滑动事件
    - (void)pressForDuration:(double)arg1 thenDragToCoordinate:(id)arg2;
    ​
    // 点击事件
    - (void)pressForDuration:(double)arg1;
    ​
    @end
    ​
    ​
    + (void)simulateClick:(CGPoint)tapPoint {
        // 在 https://github.com/appium/WebDriverAgent 项目已经有现成封装好的代码,在这就直接拿来用了
        CGFloat x = tapPoint.x;
        CGFloat y = tapPoint.y;
        CGSize screenSize = [self getScreenSize];
        double multiple = screenSize.width / remote_device_screen_width;
        x = x * multiple;
        y = y * multiple;
           
        FBRouteRequest *request = [[FBRouteRequest alloc] init];
        request.arguments = @{@"x": @(x), @"y": @(y), @"duration": @0.001};
        NSLog(@"witwit simulateClick =%@=", request.arguments);
        [FBElementCommands handleTouchAndHold:request];
    }
    

二、具体功能

iOS远程大师主要实现了以下几个功能:

  1. 实时屏幕显示:用户可以在H5界面实时查看iPhone的屏幕。
  2. 远程协助:支持键盘输入、触屏操作等常见交互方式,以便对远程iPhone进行协助。
  3. 多设备管理:支持同时连接和管理多个iPhone设备。

三、遇到的问题及解决方案

在开发过程中,我也遇到了几个主要问题:

模拟滑动:在越狱机上的模块滑动效果始终不理想,竖滑正常,横滑始终反应,折腾了好久,最终放弃,直接引用了IOS13-SimulateTouch项目代码。 越狱设备socket连接偶尔断开 :在socket失去连接时,有重新建立连接,但还是有偶现的设备掉线线情况,后边有时间再处理。 设备兼容性:在越狱机上的12,13,14系统能正常运行,非越狱机15和16部分机型能正常运行。

总结

通过开发iOS远程大师,我们不仅实现了H5后台远程查看和协助iPhone的功能,还积累了丰富的经验和技术储备。希望这篇文章能对有类似需求和兴趣的开发者提供一些参考和启发,如你需要体验该项目,请联系我。

提示:阅读此文档的过程中遇到任何问题,请关住工众好【移动端Android和iOS开发技术分享】或+99 君羊【812546729

❌
❌