wenmo8 发布的文章

【环境】kos5.8sp2, kernel5.10

最近工作中需要搭建一个软件环境,其依赖的 glibc 版本较高,因此在安装时给出了以下错误:

xxx: /lib64/libc.so.6: version 'GLIBC_2.33' not found (required by xxx)

去查看当前机器的 libc.so 支持的 GLIBC 版本,发现确实太低了:

strings /usr/lib64/libc.so.6 | grep GLIBC

而且,难受的是,这个需要安装的软件仅仅提供了一个二进制安装程序,没办法基于其源码做定制化改动。

这样看来,不得不升级当前系统的 glibc 了。

网上有很多有关替换 glibc 的教程,大都是给出了
make && make install
的方案。
然而这种方案是及其风险的。
因为 glibc 是系统的核心库之一,几乎所有的用户空间程序都依赖于它。如果不考虑风险直接
make install
,那么当新的 glibc 安装成功后,你的系统大概率会挂掉。一个有代表性的现象是,你执行一些诸如
ls
的简单 shell 指令都会报错了。

其实无痛安装 glibc 有更好的办法,那就是基于 glibc 的 rpm 源码包在本地环境编译成 rpm,再进行安装。

我的当前系统为 kos5.8SP2,与 RHEL 同根同源。因此,我找了一个 Fedora 的 glibc 安装包:
glibc-2.38-19.fc39.src.rpm
,下面开始编译。

mkdir glibc-2.38 && cd glibc-2.38

# 拆分 src.rpm
rpm2cpio ../glibc-2.38-19.fc39.src.rpm | cpio -div

# 手动拷贝到 rpmbuild/SOURCE
cp -r * ~/rpmbuild/SOURCE/

# 进入源码目录
cd ~/rpmbuild/SOURCE/

# 安装依赖
yum builddep glibc.spec

# 开始编译
rpmbuild -ba glibc.spec --nodebuginfo

编译成功后,会在
~/rpmbuild/RPMS/
目录下生成 rpm,安装即可:

cd ~/rpmbuild/RPMS/ && yum install *

安装成功后,验证一下,glibc 已经更新了:



1. 什么是自动化项目搭建

当一个软件开发工程师接到一个新的Web项目开发的时候,往往需要完成一些准备工作,例如,选择web框架,项目的目录结构设计,数据库的连接配置,Redis/Kafka连接和配置;甚至包括一些基础功能的实现和封装,例如 MySQL库增删查改操作的封装,登录功能,以及登录token的验证。这个过程通常称为
项目初始化

项目搭建

当我们的大部分项目都会用到这些基础功能,我们会将这个搭建好的项目放到一个单独的代码仓库,当需要开发新的项目时就从从这个仓库拉取代码,在这个项目代码的基础上继续开发。这项目通常被称为
种子项目

模板项目

每次从模板项目拉取代码,都需要手动修改
模板项目
的名称,例如:将
template-project
改为
company-user-project

company-payment-project
。甚至在使用
模板
项目的时候,会有个性化的需求,
company-user-project
需要使用MySQL数据库,
company-payment-project
需要使用MongoDB数据库,我们可以进一步实现脚手架工具。通过可选配置的方式生成个性化的
模板项目
,这样的工具我们通常称为
脚手架工具

图:Spring initializr

自动化测试工程师在接到新需求时,也需要完成类似项目初始化的工作(例如选择测试框架、设计目录结构、集成测试报告,以及各种主流的测试库等)。此外,也包括实现一些基础用例或封装一些通用操作,比如系统的注册、登录用例,封装随机数的生成等等。毫无疑问,这个过程也被认为是
自动化项目搭建

当我们搭建好了自动化测试项目,同样可以将其作为
模板项目
使用。然后,基于
模板项目
,我们可以更加快速的编写自动化测试用例。

为什么要介绍这个概念,是因为网上看到大量的文章将
自动化测试项目搭建
叫做
自动化测试框架开发
,这显然是错误的认知,因为两者的角度和目的是不同的。

  • 自动化测试项目搭建: 服务于公司具体业务,为了更快速地编写业务的测试用例。

  • 自动化测试框架开发: 为了解决一类通用问题,开发设计的一种通用的能力,从而定义解决问题的方法和结构。



2. 为什么设计自动化测试框架

开发框架的原因可以有多种角度,以下是比较常见的原因。

2.1 提高开发效率

现有框架可能存在以下问题:

  • 现有框架使用过程中存在过多的第三方依赖,安装和配置比较繁琐。
  • 使用复杂,测试工程师需要花费过多时间学习或适配。

通过开发更贴合需求的自动化测试框架,可以减少重复性劳动,提升开发效率。

2.2.满足特定类型需求

现有框架存在无法满足特定类型的业务的测试需求(例如,gRPC、Kafka的测试用例编写),需要在现有框架的基础上做更多的功能扩展和封装。

通过开发功能更加强大的框架,更好的解决现有业务类型的自动化测试需求。

2.3 提供更优的设计理念

提供更优的设计理念或创新技术实现。

  • 创新性设计:基于新的架构或设计模式提供更高性能、更易扩展的解决方案。
  • 领域驱动:专注于某一特定领域(如关键字驱动,数据驱动、方法链)的最佳实践。

2.4.提升团队协作

规范团队协作,提升开发体验和代码一致性。

  • 框架可以规范团队的开发方式,减少个性化差异带来的协作成本。
  • 提供标准化的工具链、模块和流程,确保团队代码质量和一致性。



3. 自动化测试框架设计的方向

当我们决定去设计自动化测试框架,那么可以有两个方向:
从零开始设计

基于单元测试框架二次开发

3.1 从零开始设计

从零开始设计自动化测试框架,例如针对一款的编程语言,单元测试框架一般需要作为基础库被设计并集成到编程语言中。

标准化的测试结构

单元测试框架提供了一种统一的结构化方式,让开发者以一致的方式组织和运行测试。

  • 生命周期方法
    : 通常框架会定义一套标准化的生命周期钩子,用于在每个测试方法执行前后进行资源管理:
    setup/beforeEach
    : 在每个测试运行之前初始化测试环境(如创建测试数据、实例化对象)。
    teardown/afterEach
    : 在每个测试运行后清理资源(如关闭数据库连接、删除临时文件)。

  • 测试方法的命名
    : 通常有特定约定,比如以
    test_
    开头(Python 的 unittest)或
    @Test
    注解(Java 的 JUnit)。 这种约定使得框架可以自动发现测试方法。

  • 独立性
    : 每个测试方法应保持独立,不依赖其他测试。测试之间的隔离有助于更快定位问题。

断言机制

断言(Assertions)是单元测试的核心,用于验证被测代码的行为是否符合预期。

断言的作用:

  1. 通过对输入和输出的验证,确保代码逻辑正确。
  2. 如果断言失败,测试会立即终止并报告错误。

测试发现

测试发现是单元测试框架的一项重要功能,能够自动找到符合规范的测试。

框架会扫描特定的模块或文件夹,找到符合命名约定的方法。例如:在 Python 中,pytest 会自动发现以
test_
开头的方法。在 Java 的 JUnit 中,
@Test
注解标识的方法会被识别为测试方法。

测试套件和测试运行器

测试套件和运行器使得开发者可以高效地组织和执行测试。

  • 测试套件(Test Suite)

测试套件是一个集合,用于将多个测试用例组合在一起运行。方便地对一组相关的测试进行分组管理。

  • 测试运行器(Test Runner)

测试运行器负责执行测试并收集测试结果。

测试报告和结果反馈

测试报告用于展示测试的执行结果,帮助开发者快速了解代码的健康状态。测试结果分类

  • 通过(Pass): 测试正常完成且所有断言成功。
  • 失败(Fail): 测试未通过,某个断言失败。
  • 错误(Error): 测试执行过程中抛出了未预期的异常。
  • 跳过(Skip/Ignore): 测试因某些条件未被执行。

扩展:xUnit 被认为是许多主流编程语言的单元测试框架的雏形和灵感来源。xUnit 是一种架构模式,最早由 Kent Beck 和 Erich Gamma 在 SUnit(Smalltalk 的单元测试框架)中提出,并成为了测试框架设计的标准。其设计思想和概念被广泛应用到其他语言中,例如 JUnit(Java)、NUnit(.NET)、pytest(Python)等。

3.2 基于单元测试框架二次开发

基于单元测试框架二次开发,在单元测试框架的基础上,更偏注重于扩展各种测试能力。

通常,单元测试框架已经提供了基础的测试能力,为了更好的支撑各种类型的测试,我们可以在此基础上进行扩展,以便于满足不同类型的需求。

基于单元测试框架二次开发的方向比较多,取决于基于框架设计的定位和目标。以下是常见的扩展功能。

数据驱动

数据驱动是自动化测试最常见的功能之一,可以有效的减少样例代码的编写,从而提高测试用例编写的效率。

  • 数据驱动装饰器

可以通过数据驱动装饰器来驱动测试测试用例(方法), 例如,Seldom框架的
@data([])
装饰器。

  • 数据驱动文件

通过取数驱动文件读取不同类型的数据文件。例如,Seldom框架的
@data_file("./data/file.json")
管理测试数据。

定制化测试报告

测试报告是自动化测试框架的非常重要的功能,我们需要对报告做一些定制化开发。

  • 个性化测试样式和内容

例如显示公司logo、人员个名称和角色等。

  • 生成不同的报告类型

不同的运行模式需要不同的报告类型。例如,本地执行需要HTML格式的报告,CI/CD 或平台化执行需要XML、JSON格式的测试报告。

脚手架工具

集成脚手架工具,可以快速的生成
自动化测试项目模板

关于自动化测试项目模板,文章的开头已经介绍,这里不再阐述。

集成消息功能

每个公司都有自己的通讯工具,邮件、钉钉、企微、飞书等。 通过调用相关工具的API,实现发消息功能,可以让测试的运行结果更快的发送给相关人员。

集成各种测试库

基于框架的定位,可以集成不同类型的测试库,并对这些库进行二次开发,使框架的使用更加高效统一。

  • Web UI测试

如果是为了实现Web UI自动化测试,那么可以集成 Selenium、Playwright等测试库,并对这些库的API进行二次封装。

  • API 测试

如果是为支持接口测试,可以集成 Requests、webSocket、gRPC等测试库,并对这些库的API进行二次封装。

  • App UI测试

如果是为了实现App UI自动化测试,那么可以集成 Appium、uiautomator2 等测试库,并对这些库的API进行二次封装。



4. 自动化测试框架设计基本准则

4.1 独立的名字和版本管理

我们应该把框架当成一个独立的项目来进行开发、维护和升级。

框架的命名

首先,应该为框架起一个独立的名字,既可以以某个动物或植物命名,比如,Python(蟒蛇)或Lettuce(生菜);也可以按照框架的本意命名,比如, Robot Framework(自动化框架)或unittest(单元测试);还可以是缩写合成词,比如,pytest = Python + Test、Appium = Application + Selenium等,关键是简单好记。

版本号管理

其次,框架应该有自己的版本号,推荐使用GNU风格的版本号命名。

格式:
主版本号.子版本号[.修正版本号[.编译版本号]]

  • 主版本号:重构版本。
  • 子版本号:重大功能改进。
  • 修正版本号:小升级或者bug修复。
  • 编译版本号:一般是编译器在编译过程中自动生成的,我们只定义其格式,并不进行人为的控制。

独立的安装

最后,框架应该提供独立的安装,比如,Python使用
pip
命令进行安装。

对于开源的项目来说,例如,需要创建setup.py或pyproject.toml打包文件,将项目打包成.whl格式的文件,提交到pypi.org官方仓库。

4.2 具备通用性

作为一款框架,其定位和目标一定是解决一类通用问题并提供能力。

例如:
数据驱动

自动化发邮件

生成随机数

数据缓存

命令行工具
这些都与具体公司业务无关,提供的是通用的能力。

4.3 清晰的定位和目标

自动化测试框架被设计的初衷一定是为更好的了解决某一类问题。在设计之初,我们应该有一个清晰的目标和定位。

从无到有地解决一类问题

xUnit在单元测试框架领域具有开创性意义。前文有对 xUnit 进行介绍。

更加简单地解决一类问题

Flask是一个使用Python编写的轻量级Web框架,通过它,我们可以只简单地编写几行代码就搭建一个Web服务。

提供更加强大且丰富的功能

Django是一个开放源代码的由Python编写的Web框架。

Django虽然学习成本较高,但是它功能提供了 ORM(关系对象映射)、Admin管理系统、模板系统、Cache系统、表单处理、会话(session)、国际化等,这些功能几乎都是开箱即用的,可以用来实现一个较为复杂的系统。

最后,当你要设计一个自动化测试框架的时候,不妨思考一下,设计目标是什么?为了解决什么问题?是否已经有更好的开源框架可以直接使用。



5. 相关书籍推荐

那么,是否有一本书能讲清楚
自动化测试框架设计
?

答案是:《自动化测试框架设计》一书

本书由虫师编著,作为 SeldomQA GitHub千星开源项目的开发者,在
自动化测试框架设计

定制化测试报告设计

设计模式
,以及
测试平台开发
方面有着深厚技术积累和独特的设计理念。

书中浅显易懂的介绍了 SeldomQA 相关项目中的诸多设计和封装技术。并且,介绍了一个开源自动化测试框架从设计到发布的整个流程。

此外,书中还介绍了如何打通
自动化框架

自动化测试平台
,这是一种独特的技术方案,为自动化测试平台提供了新的设计思路。

最后,结合当下热门的AI技术,作者还介绍AI在自动化领域的探索方向。


PrefPPO 首次(?)出现在 PEBBLE,作为 pebble 的一个 baseline,是用 PPO 复现 Christiano et al. (2017) 的 PbRL 算法。

For evaluation, we compare to Christiano et al. (2017), which is the current state-of-the-art approach using the same type of feedback. The primary differences in our method are (1) the introduction of unsupervised pre-training, (2) the accommodation of off-policy RL, and (3) entropy-based sampling. We re-implemented Christiano et al. (2017) using the state-of-the-art on-policy RL algorithm: PPO (Schulman et al., 2017). We use the same reward learning framework and ensemble disagreement-based sampling as they proposed. We refer to this baseline as Preference PPO.

Christiano et al. (2017) 这篇文章的题目是 Deep reinforcement learning from human preferences,发表在 NeurIPS 2017;arxiv:
https://arxiv.org/abs/1706.03741
,GitHub:
https://github.com/mrahtz/learning-from-human-preferences(用
TensorFlow 实现的)。

01 论文阅读:Deep reinforcement learning from human preferences

1.1 intro

intro:

  • 大规模应用 RL 的限制是,许多任务涉及复杂、定义不明确或难以指定的目标。(举了一些例子)
  • 如果有人类 demo(专家数据),可以 inverse RL 或 behavior cloning,但是很多任务难以给出人类 demo,比如控制奇形怪状的 跟人类很不像的机器人。
  • 我们的 PbRL 思路:从 human feedback 中学习 reward model,当作 RL 中的奖励函数。这可以解决上面的两个问题,允许 non-expert user 来给 feedback,并且(据论文说)可以节省一个数量级的 feedback 数量。
  • human feedback 的形式:让 human 看两段 video,指出哪一段更好(即 preference)。

Related Work:

  • 列举了很多从 human 打分或排序中学 RL 的工作。还有一些工作在 RL 之外的 setting 使用 preference。
  • Akrour 2012 和 2014 年的工作,貌似也算是 PbRL,但他们的方法是在整个 trajectory 上打 preference,不像我们只需要比较两个较短的 video segment。
    • Akrour 2012:April: Active preference learning-based reinforcement learning. Joint European Conference on Machine Learning and Knowledge Discovery in Databases(好像是 ECML PKDD,不知道是什么概念)2012。
    • Akrour 2014:Programming by feedback. ICML 2014。
  • 从 feedback 里学 reward model 的方法,貌似跟 Wilson et al. (2012)工作比较像。
    • Wilson et al. (2012):A Bayesian approach for policy learning from trajectory preference queries. NeurIPS 2012。

1.2 Method

首先介绍了一下 PbRL 的 setting:

  • 定义 segment
    \(\sigma = ((s_0, a_0), ... (s_{k-1}, a_{k-1}))\)
    ,是长为 k 的轨迹段。
  • 定义 preference
    \(\sigma_0\succ\sigma_1\)
    表示轨迹段 σ0 比 σ1 更好。接下来,我们用这些 preference 数据学出 reward model。

然后是 method:(发现这篇文章好像没给伪代码)

  • 我们要维护一个 reward model
    \(\hat r\)
    ,和一个 policy
    \(\pi\)
  • 大概重复这样的过程:
    • 用目前的 policy
      \(\pi\)
      生成 query
      \((\sigma_0,\sigma_1)\)
    • 把 query 给 human 比较得到 preference 数据;
    • 用新 preference 数据学 reward model;
    • 把 reward model 当作奖励函数,去学 RL、更新 policy
      \(\pi\)

① 把 reward model 当作奖励函数,去学 RL、更新 policy
\(\pi\)

  • 论文声称
    \(\hat r\)
    可能是非平稳的(因为 reward model 一直在更新),所以他们想用 policy gradient 方法,因为 policy gradient 方法对奖励函数具有鲁棒性。
  • 他们用 A2C(advantage actor-critic)做 Atari,用 TRPO 做 MuJoco。调整了 TRPO 的 entropy bonus,MuJoCo 的 swimmer 任务使用 0.001 的 entropy bonus,其他任务使用 0.01 的 entropy bonus。
  • 把 reward model 产生的
    \(\hat r\)
    归一化到 0 均值 1 标准差。

② 用目前的 policy
\(\pi\)
生成 query
\((\sigma_0,\sigma_1)\)

  • preference 数据的形式是
    \((\sigma_0,\sigma_1, p)\)
    ,其中 p 是一个 {0,1} 上的分布。
  • 如果 human 能打出 preference,那么 p = 0 或 1。如果 human 分不出来,则 p 是 01 上的均匀分布。如果 human 感觉 query 是不可比的,那么不会使用这个 query 学 reward model。

③ 用新 preference 数据学 reward model:

  • 我们使用 Bradley-Terry model 来建模
    \(\hat r\)
    和 preference 之间的关系:


  • \[\hat P[\sigma_0\succ \sigma_1] = \frac{\exp\sum\hat r(s_t^0,a_t^0)}
    {\exp\sum\hat r(s_t^0,a_t^0) + \exp\sum\hat r(s_t^1,a_t^1)} ~~.
    \tag{1}
    \]

  • 然后,我们去优化 cross-entropy loss:


  • \[L(\hat r) = -\sum_{(\sigma_0,\sigma_1,p)} \left(
    p(0)\log \hat P[\sigma_0\succ \sigma_1] + p(1)\hat P[\sigma_1\succ \sigma_0]\right)
    \tag{2}
    \]

  • (以上流程已经变成经典的 PbRL 做法)

  • 他们还加了三个小 trick:


    • 对 reward model 进行 ensemble,学 n 个独立的 reward model,用它们每个人单独归一化后的值 取平均 作为
      \(\hat r\)
      的值。
    • 把一部分 preference 数据拿出来做验证集,以调整神秘的 L2 正则化的权重参数,并添加神秘 dropout,以避免 reward model 过拟合(?)
    • label smoothing(?)貌似是当 human 打出 p = 0 的时候,认为 p = 0 的概率是 0.95,p = 1 的概率是 0.05。

query selection:

  • 即,我们现在有很多 trajectory,要从里面截出 segment、组成 segment pair,作为 query 送给人去比较。应该如何选取 segment pair 作为 query?
  • 这里使用了基于 disagreement 的 query selection,貌似是让每个 reward model 给出
    \(\hat P[\sigma_0\succ \sigma_1]\)
    的值,计算这些值的方差,然后选一个方差最大的 query。

1.3 实验结果

算法是在 TensorFlow 中写的。Github:
https://github.com/mrahtz/learning-from-human-preferences(貌似不是官方代码…)

  • preference 一些是人类打的,另一些是 scripted teacher 打的。
    • Appendix B 中有让人类打 preference 的一些 prompt,感觉很有趣。
    • scripted teacher:上面公式 (1) 中用任务的真 reward 替换
      \(\hat r\)
      ,反向生成 preference。
  • MuJoCo 实验中,分别使用真 reward、1400 700 350 个 scripted teacher queries、和 750 个 human queries。
    • (个人理解,这里的 750 human queries 包含的 label 少于 750 个,因为人类认为不可比的 query 应该会直接扔掉)
    • MuJoCo 实验中,很多 task 都做了 1e7 步,相比 pebble 来说学的很慢;pebble 1e6 步就能学出来。
  • Atari 实验中,分别使用真 reward、10k 5.6k 3.3k 个 scripted teacher queries、和 5.5k 个 human queries。(好多 human label…… 这要打好久好久;如此充足的实验,真是 solid work 呀)
  • 这些实验没有得出 human label 比 scripted teacher 好用的结论,论文说,可能是因为 human 犯错、不同 human 的打标签准则不一样等原因。
  • 一些 Appendix A 里的实验细节:
    • 有些环境会提前结束 episode,比如当 agent 倒下、死亡或到达目的地(?)他们声称这种 done 信号可能会提供 task 信息,因此魔改了环境,规避掉了 episode 长度可变的情况(?)使得 agent 死亡时得到一个大惩罚,或者 agent 到达目的地的时候直接 reset env 而不 done(?)
    • 在 MuJoCo 中,在实验开始前直接用随机 policy 打 25% 的 queries,然后以一个随时间递减的 rate 来打 preference;segment length = 15 - 60,取决于具体 task。
    • 在 Atari 中,reward model 和 policy 的输入是一样的,都是一个处理图像的卷积网络(无端联想,听说 DPO 的主要贡献是可以省掉 LLM RLHF 的 reward model,因为 reward model 应该跟 policy 一样大,所以省掉它可以节约很多显存)。
    • Atari 其实跑的是 A3C,在实验开始前打 500 个 query,segment length = 25。
  • 3.2 节还在 MuJoCo 里学习了 hopper 后空翻的 novel behavior,据文章说,可以保证后空翻后 hopper 脚着地。使用 900 个 human query 学习的。3.2 节还有其他的 novel behavior。
  • 3.3 节做了非常充分的 ablation study。发现 segment 长度 = 1 貌似会性能变差,reward model 不加正则化影响不大,把 query selection 改成随机好像也影响不大,以及,最好边更新 policy 边拿最新轨迹打 preference。

02 PEBBLE 中的 PrefPPO 实现

PEBBLE 中的 PrefPPO 实现,直接魔改了 stable_baselines3 的 PPO 模型;他们写了一个叫做 PPO_REWARD 的新类,把所有跟 reward model 的交互都封装到 model.learn() 函数里了。

2.1 reward model 如何构建

跟 pebble 的 reward model 一样。

如果 state 和 action 都是了连续的(比如普通的 cheetah walker),那么就把 state 和 action concat 起来,作为 reward model 的输入。

如果 state 是图像,action 是离散的(比如 Christiano 2017 论文中的 Pong 环境),那么(按照 Christiano 2017 复现代码),…… 好像直接拿 state 图像来算 reward 了,没有 concat 一个 one-hot action 或者数值 action。

2.2 PPO_REWARD 的 model.learn()

PPO 的大概流程:收集 rollout → 计算 rollout 的 advantage 之类 → 计算 loss 并 backward → 收集新 rollout…

在收集 rollout 的过程中,PrefPPO 把要收集的真实 task reward 替换成了
\(\hat r\)
,并把 rollout 数据都添加到 query 的备选中。

在收集 rollout 前后,貌似都有调用 learn_reward() 函数来训练 reward model,这个函数首先收集 query,然后拿收集的 query 学习 reward model。跟 pebble 基本一样。



【引言】

本项目是一个简单的随机数生成器应用,用户可以通过设置随机数的范围和个数,并选择是否允许生成重复的随机数,来生成所需的随机数列表。生成的结果可以通过点击“复制”按钮复制到剪贴板。

【环境准备】

• 操作系统:Windows 10
• 开发工具:DevEco Studio NEXT Beta1 Build Version: 5.0.3.806
• 目标设备:华为Mate60 Pro
• 开发语言:ArkTS
• 框架:ArkUI
• API版本:API 12

【关键技术点】

1. 用户界面设计

用户界面主要包括以下几个部分:

• 标题栏:显示应用名称。
• 输入框:用户可以输入随机数的起始值、结束值和生成个数。
• 开关:用户可以选择生成的随机数是否允许重复。
• 生成按钮:点击后生成随机数。
• 结果显示区:显示生成的随机数,并提供复制功能。

2. 随机数生成算法

随机数生成是本项目的重点。根据用户是否允许生成重复的随机数,算法分为两种情况:

2.1 不允许重复

当用户选择不允许生成重复的随机数时,程序使用一个 Set 来存储生成的随机数,利用 Set 的特性自动去重。具体步骤如下:

1)计算范围:计算用户指定的随机数范围 range = endValue - startValue + 1。

2)生成随机数:使用一个临时数组 tempArray 来辅助生成不重复的随机数。每次生成一个随机索引 randomIndex,从 tempArray 中取出或计算一个新的随机数 randomNum,并将其添加到 Set 中。

3)更新临时数组:将 tempArray 中末尾的元素移动到随机位置,以确保下次生成的随机数仍然是唯一的。

if (!this.isUnique) {
  if (countValue > range) {
    // 显示错误提示
    this.getUIContext().showAlertDialog({
      title: '错误提示',
      message: `请求的随机数数量超过了范围内的总数`,
      confirm: {
        defaultFocus: true,
        value: '我知道了',
        fontColor: Color.White,
        backgroundColor: this.primaryColor,
        action: () => {}
      },
      onWillDismiss: () => {},
      alignment: DialogAlignment.Center,
    });
    return;
  }
  for (let i = 0; i < countValue; i++) {
    let randomIndex = Math.floor(Math.random() * (range - i));
    let randomNum = 0;
    if (tempArray[randomIndex] !== undefined) {
      randomNum = tempArray[randomIndex];
    } else {
      randomNum = startValue + randomIndex;
    }
    generatedNumbers.add(randomNum);
    if (tempArray[range - 1 - i] === undefined) {
      tempArray[range - 1 - i] = startValue + range - 1 - i;
    }
    tempArray[randomIndex] = tempArray[range - 1 - i];
  }
  this.generatedNumbers = JSON.stringify(Array.from(generatedNumbers));
}

3. 剪贴板功能

为了方便用户使用,程序提供了将生成的随机数复制到剪贴板的功能。具体实现如下:

private copyToClipboard(text: string): void {
  const pasteboardData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
  const systemPasteboard = pasteboard.getSystemPasteboard();
  systemPasteboard.setData(pasteboardData);
  promptAction.showToast({ message: '已复制' });
}

【完整代码】

// 导入剪贴板服务模块,用于后续实现复制功能
import { pasteboard } from '@kit.BasicServicesKit';
// 导入用于显示提示信息的服务
import { promptAction } from '@kit.ArkUI';

// 使用装饰器定义一个入口组件,这是应用的主界面
@Entry
@Component
struct RandomNumberGenerator {
  // 定义基础间距,用于布局中的间距设置
  @State private baseSpacing: number = 30;
  // 存储生成的随机数字符串
  @State private generatedNumbers: string = '';
  // 应用的主题色
  @State private primaryColor: string = '#fea024';
  // 文本的颜色
  @State private fontColor: string = "#2e2e2e";
  // 输入框是否获取焦点的状态变量
  @State private isFocusStart: boolean = false;
  @State private isFocusEnd: boolean = false;
  @State private isFocusCount: boolean = false;
  // 是否允许生成的随机数重复
  @State private isUnique: boolean = true;
  // 随机数生成的起始值
  @State private startValue: number = 0;
  // 随机数生成的结束值
  @State private endValue: number = 0;
  // 要生成的随机数个数
  @State private countValue: number = 0;

  // 生成随机数的方法
  private generateRandomNumbers(): void {
    const startValue = this.startValue; // 获取当前设定的起始值
    const endValue = this.endValue; // 获取当前设定的结束值
    const countValue = this.countValue; // 获取当前设定的生成个数
    const range: number = endValue - startValue + 1; // 计算生成范围


    // 用于存储生成的随机数
    const generatedNumbers = new Set<number>(); // 使用Set来自动去重
    const tempArray: number[] = []; // 临时数组,用于辅助生成不重复的随机数

    // 如果不允许重复,则使用去重算法生成随机数
    if (!this.isUnique) {
      // 如果请求的随机数数量超过了范围内的总数,则显示错误提示
      if (countValue > range) {
        this.getUIContext().showAlertDialog({
          title: '错误提示',
          message: `请求的随机数数量超过了范围内的总数`,
          confirm: {
            defaultFocus: true,
            value: '我知道了',
            fontColor: Color.White,
            backgroundColor: this.primaryColor,
            action: () => {} // 点击确认后的回调
          },
          onWillDismiss: () => {}, // 对话框即将关闭时的回调
          alignment: DialogAlignment.Center, // 对话框的对齐方式
        });
        return;
      }

      for (let i = 0; i < countValue; i++) {
        let randomIndex = Math.floor(Math.random() * (range - i)); // 在剩余范围内选择一个随机索引
        let randomNum = 0;
        if (tempArray[randomIndex] !== undefined) { // 如果索引位置已有值,则使用该值
          randomNum = tempArray[randomIndex];
        } else {
          randomNum = startValue + randomIndex; // 否则计算新的随机数
        }
        generatedNumbers.add(randomNum); // 添加到Set中,自动去重
        if (tempArray[range - 1 - i] === undefined) { // 更新末尾元素的位置
          tempArray[range - 1 - i] = startValue + range - 1 - i;
        }
        tempArray[randomIndex] = tempArray[range - 1 - i]; // 将末尾元素移到随机位置
      }
      // 将生成的随机数转换成JSON格式的字符串
      this.generatedNumbers = JSON.stringify(Array.from(generatedNumbers));
    } else {
      // 如果允许重复,则直接生成随机数
      for (let i = 0; i < this.countValue; i++) {
        let randomNumber = this.startValue + Math.floor(Math.random() * (this.endValue - this.startValue));
        tempArray.push(randomNumber);
      }
      // 将生成的随机数转换成JSON格式的字符串
      this.generatedNumbers = JSON.stringify(tempArray);
    }
  }

  // 将生成的随机数复制到剪贴板的方法
  private copyToClipboard(text: string): void {
    const pasteboardData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text); // 创建剪贴板数据
    const systemPasteboard = pasteboard.getSystemPasteboard(); // 获取系统剪贴板
    systemPasteboard.setData(pasteboardData); // 设置剪贴板数据
    // 显示复制成功的提示信息
    promptAction.showToast({ message: '已复制' });
  }

  // 构建页面布局的方法
  build() {
    Column() {
      // 标题栏,展示应用名
      Text("随机数生成")
        .width('100%') // 设置宽度为100%
        .height(54) // 设置高度为54
        .fontSize(18) // 设置字体大小为18
        .fontWeight(600) // 设置字体粗细为600
        .backgroundColor(Color.White) // 设置背景颜色为白色
        .textAlign(TextAlign.Center) // 设置文本居中对齐
        .fontColor(this.fontColor); // 设置文本颜色

      // 随机数范围设置区域
      Column() {
        Row() {
          Text(`随机数范围`)
            .fontWeight(600) // 设置字体粗细为600
            .fontSize(18) // 设置字体大小为18
            .fontColor(this.fontColor); // 设置文本颜色
        }
        .margin({ top: `${this.baseSpacing}lpx`, left: `${this.baseSpacing}lpx` }); // 设置边距

        // 输入随机数范围的两个值
        Row() {
          TextInput({ placeholder: '开始(>=)' }) // 输入框,显示占位符
            .layoutWeight(1) // 设置布局权重为1
            .type(InputType.Number) // 设置输入类型为数字
            .placeholderColor(this.isFocusStart ? this.primaryColor : Color.Gray) // 设置占位符颜色
            .fontColor(this.isFocusStart ? this.primaryColor : this.fontColor) // 设置文本颜色
            .borderColor(this.isFocusStart ? this.primaryColor : Color.Gray) // 设置边框颜色
            .borderWidth(1) // 设置边框宽度
            .borderRadius(10) // 设置圆角半径
            .backgroundColor(Color.White) // 设置背景颜色
            .showUnderline(false) // 不显示下划线
            .onBlur(() => this.isFocusStart = false) // 输入框失去焦点时的处理
            .onFocus(() => this.isFocusStart = true) // 输入框获得焦点时的处理
            .onChange((value: string) => this.startValue = Number(value)); // 输入值变化时的处理

          // 分隔符
          Line().width(10) // 设置分隔符宽度

          TextInput({ placeholder: '结束(<=)' }) // 输入框,显示占位符
            .layoutWeight(1) // 设置布局权重为1
            .type(InputType.Number) // 设置输入类型为数字
            .placeholderColor(this.isFocusEnd ? this.primaryColor : Color.Gray) // 设置占位符颜色
            .fontColor(this.isFocusEnd ? this.primaryColor : this.fontColor) // 设置文本颜色
            .borderColor(this.isFocusEnd ? this.primaryColor : Color.Gray) // 设置边框颜色
            .borderWidth(1) // 设置边框宽度
            .borderRadius(10) // 设置圆角半径
            .backgroundColor(Color.White) // 设置背景颜色
            .showUnderline(false) // 不显示下划线
            .onBlur(() => this.isFocusEnd = false) // 输入框失去焦点时的处理
            .onFocus(() => this.isFocusEnd = true) // 输入框获得焦点时的处理
            .onChange((value: string) => this.endValue = Number(value)); // 输入值变化时的处理
        }
        .margin({
          left: `${this.baseSpacing}lpx`, // 左边距
          right: `${this.baseSpacing}lpx`, // 右边距
          top: `${this.baseSpacing}lpx`, // 上边距
        });

        // 输入生成随机数的个数
        Text('生成随机数个数')
          .fontWeight(600) // 设置字体粗细为600
          .fontSize(18) // 设置字体大小为18
          .fontColor(this.fontColor) // 设置文本颜色
          .margin({ left: `${this.baseSpacing}lpx`, top: `${this.baseSpacing}lpx` }); // 设置边距

        Row() {
          TextInput({ placeholder: '' }) // 输入框,显示占位符
            .layoutWeight(1) // 设置布局权重为1
            .type(InputType.Number) // 设置输入类型为数字
            .placeholderColor(this.isFocusCount ? this.primaryColor : Color.Gray) // 设置占位符颜色
            .fontColor(this.isFocusCount ? this.primaryColor : this.fontColor) // 设置文本颜色
            .borderColor(this.isFocusCount ? this.primaryColor : Color.Gray) // 设置边框颜色
            .borderWidth(1) // 设置边框宽度
            .borderRadius(10) // 设置圆角半径
            .backgroundColor(Color.White) // 设置背景颜色
            .showUnderline(false) // 不显示下划线
            .onBlur(() => this.isFocusCount = false) // 输入框失去焦点时的处理
            .onFocus(() => this.isFocusCount = true) // 输入框获得焦点时的处理
            .onChange((value: string) => this.countValue = Number(value)); // 输入值变化时的处理
        }
        .margin({
          left: `${this.baseSpacing}lpx`, // 左边距
          right: `${this.baseSpacing}lpx`, // 右边距
          top: `${this.baseSpacing}lpx`, // 上边距
        });

        // 设置数字是否可重复的开关
        Row() {
          Text('数字是否可重复')
            .fontWeight(400) // 设置字体粗细为400
            .fontSize(16) // 设置字体大小为16
            .fontColor(this.fontColor) // 设置文本颜色
            .layoutWeight(1); // 设置布局权重为1

          Toggle({ type: ToggleType.Checkbox, isOn: this.isUnique }) // 切换按钮
            .width('100lpx') // 设置宽度
            .height('50lpx') // 设置高度
            .borderColor(Color.Gray) // 设置边框颜色
            .selectedColor(this.primaryColor) // 设置选中时的颜色
            .onChange((isOn: boolean) => this.isUnique = isOn) // 切换状态变化时的处理
            .align(Alignment.End); // 设置对齐方式为右对齐
        }
        .margin({
          top: `${this.baseSpacing}lpx`, // 上边距
        })
        .width('100%') // 设置宽度为100%
        .padding({
          left: `${this.baseSpacing}lpx`, // 左内边距
          right: `${this.baseSpacing}lpx`, // 右内边距
          top: `${this.baseSpacing / 3}lpx`, // 上内边距
        })
        .hitTestBehavior(HitTestMode.Block) // 设置点击测试行为
        .onClick(() => this.isUnique = !this.isUnique); // 点击时切换状态

        // 生成随机数的按钮
        Text('开始生成')
          .fontColor(Color.White) // 设置文本颜色为白色
          .backgroundColor(this.primaryColor) // 设置背景颜色为主题色
          .height(54) // 设置高度为54
          .textAlign(TextAlign.Center) // 设置文本居中对齐
          .borderRadius(10) // 设置圆角半径
          .fontSize(18) // 设置字体大小为18
          .width(`${650 - this.baseSpacing * 2}lpx`) // 设置宽度
          .margin({
            top: `${this.baseSpacing}lpx`, // 上边距
            left: `${this.baseSpacing}lpx`, // 左边距
            right: `${this.baseSpacing}lpx`, // 右边距
            bottom: `${this.baseSpacing}lpx` // 下边距
          })
          .clickEffect({ level: ClickEffectLevel.HEAVY, scale: 0.8 }) // 设置点击效果
          .onClick(() => this.generateRandomNumbers()); // 点击时生成随机数
      }
      .width('650lpx') // 设置宽度
      .margin({ top: 20 }) // 设置上边距
      .backgroundColor(Color.White) // 设置背景颜色为白色
      .borderRadius(10) // 设置圆角半径
      .alignItems(HorizontalAlign.Start); // 设置水平对齐方式为左对齐

      // 显示生成的随机数
      Column() {
        Text(`生成的随机数为:`)
          .fontWeight(600) // 设置字体粗细为600
          .fontSize(18) // 设置字体大小为18
          .fontColor(this.fontColor) // 设置文本颜色
          .margin({
            top: `${this.baseSpacing}lpx`, // 上边距
            left: `${this.baseSpacing}lpx`, // 左边距
          });

        Text(`${this.generatedNumbers}`) // 显示生成的随机数
          .width('650lpx') // 设置宽度
          .fontColor(this.primaryColor) // 设置文本颜色为主题色
          .fontSize(18) // 设置字体大小为18
          .textAlign(TextAlign.Center) // 设置文本居中对齐
          .padding({ left: 5, right: 5 }) // 设置内边距
          .margin({
            top: `${this.baseSpacing / 3}lpx` // 上边距
          });

        // 复制生成的随机数到剪贴板的按钮
        Text('复制')
          .enabled(this.generatedNumbers ? true : false) // 按钮是否可用
          .fontColor(Color.White) // 设置文本颜色为白色
          .backgroundColor(this.primaryColor) // 设置背景颜色为主题色
          .height(54) // 设置高度为54
          .textAlign(TextAlign.Center) // 设置文本居中对齐
          .borderRadius(10) // 设置圆角半径
          .fontSize(18) // 设置字体大小为18
          .width(`${650 - this.baseSpacing * 2}lpx`) // 设置宽度
          .margin({
            top: `${this.baseSpacing}lpx`, // 上边距
            left: `${this.baseSpacing}lpx`, // 左边距
            right: `${this.baseSpacing}lpx`, // 右边距
            bottom: `${this.baseSpacing}lpx` // 下边距
          })
          .clickEffect({ level: ClickEffectLevel.HEAVY, scale: 0.8 }) // 设置点击效果
          .onClick(() => this.copyToClipboard(this.generatedNumbers)); // 点击时复制随机数
      }
      .width('650lpx') // 设置宽度
      .backgroundColor(Color.White) // 设置背景颜色为白色
      .borderRadius(10) // 设置圆角半径
      .margin({ top: `${this.baseSpacing}lpx` }) // 设置上边距
      .alignItems(HorizontalAlign.Start); // 设置水平对齐方式为左对齐
    }
    .height('100%') // 设置高度为100%
    .width('100%') // 设置宽度为100%
    .backgroundColor("#f2f3f5"); // 设置背景颜色
  }
}

大家好,我是程序员鱼皮。

大家如果平时使用网站或产品时出现了问题,一般都会去寻找 “联系客服” 的位置,从而获得人工的帮助。我们团队的面试刷题产品 - 面试鸭最近就遇到了这样一个难题:明明我们网站右下角就有联系客服按钮、而且我们每道面试题目下方都有反馈按钮,但是很多用户还是不知道怎么给我们反馈问题。

我们利用第三方网站统计工具进行分析,发现整整一个月客服按钮的点击数竟然才 20 多?!感觉毛用没有啊!

于是,我就拉着开发同学一起开会讨论,希望解决这个问题。

我说:作为良心产品,我要让用户都知道怎么找我们反馈问题!怎么实现我不管,明天上线!

开发小 A 当时就这个表情:

结果,一天后,小 A 竟然真的拿电脑来给我看效果了。我一看,立刻就红温了,就跟刚煮熟的螃蟹似的:

这。。这 ** 是客服?我们这可是一个正经网站啊!

不过仔细想一想,感觉还挺不错的,这下网站可真是充满了吸引力啊!应该会有很多同学点击了(狗头)。

我本来以为这个客服形象需要花费很长时间开发,结果小 A 跟我说:可简单了,一会儿就搞定了~

于是,我也去学习了解了一下,确实简单,这篇文章就分享给大家。

⭐️ 喜欢看视频的同学,更推荐看视频教程哦:
https://bilibili.com/video/BV1FeUaYDEKr/

网站看板娘教程

其实前面我们看到的动漫看板娘,是运用了 Live2D 技术实现的。

Live2D 是一种将 2D 图像转换为各种动画效果的技术。通过骨骼动画和物理引擎等技术,能够实现类似 3D 的立体动画效果,但实际仍然是 2D 图像的变形和运动。

相比于 GIF 图片,Live2D 模型可以实时和用户进行互动,更加吸引用户。

怎么样,是不是迫不及待也想给自己的网站整一个呢?

下面只用 1 分钟,手把手教你怎么给网站快速添加一个可爱的 Live2D 看板娘。对于提高网页点击率和用户访问时长来说,应该还是很有帮助的。

来不及学的朋友,记得点个收藏,以后说不定用得上。

一、准备工作

首先,我们需要准备 2 样东西:一套 Live2D 模型文件,和让网站加载模型的 JS 脚本。

1、模型文件

每个 Live2D 模型都对应了一组文件,定义了模型信息、物理效果、姿势动作、图片资源等,结构十分复杂:

模型文件结构

而且模型文件格式又分为 MOC 和 MOC3 两个版本,MOC3 的模型不仅视觉效果更好,还支持更复杂、更自然流畅的动作,但相应的文件结构会更复杂。我们开头看到的网站中的二次元红衣子女,就是用的 MOC3 的模型。

虽然模型复杂,不过别担心,我们可以通过 Live2D 官网和 GitHub 的开源项目搜到现成的模型文件:

友情提示,由于 Live2D 的开发是需要大量时间精力的,
一般高质量的模型都有版权保护,所以请大家谨慎使用

2、JS 脚本

有了模型文件,我们怎么让它出现在网站上呢?这就需要 JS 脚本。

我们可以利用 WebGL 这一主流的图形渲染技术来编写 JS 脚本。但这个技术有一定的复杂度和学习成本,我想哪怕你是一个经验丰富的网站开发者,也不会想要自己写 WebGL 代码去加载 Live2D 模型的。

我们可以使用一些现成的库来简化编码,比如 2D 渲染引擎 pixi.js,或者使用 Live2D 官方提供的 Web SDK。

Live2D 官方文档

但是,如果你没有两把刷子,估计看不懂那破朔迷离的官方文档。所以呢,要说贴心,还得是咱广大网友,在 GitHub 上开源了不少开箱即用的 Live2D 加载库。

经过我的一番对比,还是 star 数高的这个库最好用。使用它,不用自己写一行代码,就能给网站增加看板娘!下面来试一试。

开源仓库:
https://github.com/stevenjoezhang/live2d-widget
(不包含模型)

二、快速接入看板娘

如果你是小白,只需要复制下面这一行自动加载脚本的代码, 放到你网站 html 页面的
head

body
中,就可以加载看板娘:

<script src="https://fastly.jsdelivr.net/gh/stevenjoezhang/live2d-widget@latest/autoload.js"></script>

来,运行网页看下效果,看板娘直接就出来了有没有!还支持对话、换模型、换衣服、截图功能:

哎呀,突然感觉自己成为前端大佬了,好有成就感啊!

为什么只用这一行代码,就实现了呢?

其实,我们刚刚是加载了项目作者在远程服务器上为我们提供的 JS 脚本,这个脚本会帮我们从远程服务器下载模型文件。

这就意味着什么呢?如果你想要自己定义加载哪些模型、以及对话的内容,就不是很方便,因为你无法登录到别人的服务器去修改脚本。

作者虽然也考虑到了这一点,但他的做法是搞了个 PHP 的后端项目,通过接口的方式来动态获取要加载的模型列表和对话信息。

我就给网站加个看板娘,你还让我去搭一个后端?还让我去搞 PHP?那我必然是不会这么干的!

下面鱼皮教大家一种更简单的方式,来自定义看板娘。

三、自定义

1、改造项目

首先,将上述开源项目完整下载到本地,用一个编辑器打开。

找到
autoload.js
文件,这是整个 Live2D 加载的入口,我们可以看到默认情况下是从远程地址加载的模型列表和对话信息。

其中,initWidget 方法非常关键,顾名思义,作用是初始化组件。点进去,可以看到加载组件的方法(loadWidget),这里新建了一个模型对象。

再点进去,就进入了模型定义文件,默认情况下,是通过网络请求从远程服务器上加载的模型列表配置。

那我们只要把这段逻辑改为从本地加载我自己写的模型列表配置,就可以了呀~

没错,想到这里,可能有些同学就直接去爆改这个源文件了,但这是很不优雅的!

我们可以复制该模型定义文件,得到一个新的
本地模型定义文件
,保证函数和大多数代码不变,只需要将部分代码修改为加载本地特定位置下的模型文件和模型配置文件。

这段代码大家暂时不用关注,文末我会分享源码

然后,修改加载组件的方法,根据开发者传入的配置信息,决定是否从本地加载模型,不就可以了么?

做完这些之后,你需要重新打包一下修改完的文件。由于项目用了 npm 进行管理,你需要先安装 Node.js 服务,然后在项目内安装依赖、再执行 build 打包,就得到了新的加载文件(waifu-tips.js)。

2、效果展示

改造完毕,下面我们来使用一下改造后的项目吧~

先随便写一个小网站:

<!DOCTYPE html>
<html lang="en">
 <head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Live2D 看板娘</title>
 </head>
 <body>
   <script src="./autoload.js"></script>
 </body>
</html>

将模型文件和模型列表配置文件放到项目根目录中:

大家可以自行修改模型列表配置文件
model_list.json
(数组的每个元素都是一个模型、二维数组的每个元素是不同的皮肤):

最后,修改 autoload.js 加载文件的配置,包括将 live2d 路径改为当前路径,修改初始化组件配置为本地(使用本地模型、指定模型文件路径、模型列表配置文件的路径),还可以自定义要使用的工具:

// 改为相对路径
const live2d_path "./";
// ...
// 初始化组件
initWidget({
 isLocalModel: true, // 使用本地模型
 waifuPath: live2d_path "waifu-tips.json",
 modelsPath: live2d_path "model",
 modelListPath: live2d_path "model/model_list.json",
 tools: ["hitokoto", "asteroids", "switch-model", "switch-texture", "photo", "info", "quit"]
});

大功告成,我们来双击文件运行一下~

结果,运行失败啦!不是哥们,你人呢?

为什么模型没加载出来呢,看下 F12 控制台的报错就知道了:

这是因为浏览器为了安全,限制了从本地文件系统直接加载资源的能力。所以我们需要在本地启动一个服务器来运行网页,这一步难不倒大家。可以直接用开发工具自带的服务器,点一下图标就能正确运行了,也可以自己安装一个 serve 工具。

这样,我们的 Live2D 看板娘,就完美加载出来啦~

四、更多操作

接下来你还可以通过修改配置文件(waifu-tips.json)来自定义对话,通过修改工具文件
tools.js
来自定义点击模型旁边图标时执行的操作,通过加一些 CSS 代码来调整模型的位置等等。

改完后不要忘记重新 build 打包哦

还可以通过 F12 查看到对应的元素,修改样式文件调整位置,比如:

<style>
 #waifu {
   right: 40px !important;
   left: unset !important;
}
</style>


以上就是本期分享,我把魔改后的代码开源到了
自己的 GitHub 仓库
,大家可以直接使用,让你的网站魅力无穷!

不过代价就是模型文件比较大,可能会消耗大量的带宽和流量。我们也在纠结是否真的要给网站应用这个功能,欢迎大家给我们一些建议吧~

更多编程学习资源