开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Modal管理-看这篇文章就够了 (实践篇) 前言 Moda

发表于 2023-12-22

这是我们团队号工程化系列文章的第 3 篇,全系列文章如下:

  • 字节三年,谈谈一线团队如何搞工程化一(全景篇)
  • ⚡️卡顿减少 95% — 记一次React性能优化实践(性能篇)

团队尚有HC,感兴趣的小伙伴可以私信~(注明期望岗位城市:北京、上海、杭州)

前言

“别再弹框了,每次都是弹框,弹框套弹框用户怎么用啊?”

“OK OK,不用弹框你说用什么?”

上述对话在无数个场景下重复发生,也侧面说明在中台项目开发过程中,模态框(Modal、Drawer 等)元素在各业务系统中随处可见,受到广大产品/设计同学的偏爱,我们前端研发同学对它也是“又爱又恨”,即依赖它解决问题又被随之提升的代码复杂度提升所困扰。这里有同学就会说了,明明是一个基础组件,使用方法也简单,有啥“复杂度”可言?


OK OK,那我们接着往下看~

Modal 三宗罪

让我们从一个实际的业务需求出发,分析一下对着 Modal 组件直接撸代码会有哪些问题。


假设设计稿有以下几个需求点:

  1. 点击审批工单按钮,拉起 Modal 弹窗
  2. 弹窗需要展示当前工单的基本信息,以及审批状态
  3. 弹窗支持填写备注
  4. 弹窗支持通过/拒绝

当这样一个需求扔过来,基本所有前端都能直接秒了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
tsx复制代码const [visible, setVisible] = useState(false)
const [posting, setPosting] = useState(false)
const [record, setRecord] = useState({})
const [form] = Form.useForm()

const handleOk = () => {...}

return <div>
<Button onClick={() => }>{...}</Buton>

<Modal visible={visible} confirmLoading={posting} {...}>
<div>{record.title}</div>
<Form form={form}>
<Form.Item><Input /></Form.Item>
</Form>
</Modal>
</div>

所以我们把需求调整一下,依然是常见的业务需求,实际代码开发难度还是不高-用上面的逻辑继续搬砖就行,不过这时候一些问题就逐渐暴露出来了。

其实每个弹窗的复杂度都不高,但是如果将所有逻辑都堆积在主组件中,将引发以下 3 个问题。

污染业务代码,状态管理复杂

状态越加越多,而且这些状态都与主流程无关

通过分析示例代码不难发现,为了实现这个业务模态框,直接在组件中引入了 visible、record、posting 共 3 个状态,以及一个 form 实例。可实际上,对于主流程来说,审批流程只是一个分支,唯一所需的交互不过是审批完成后刷新工单状态。

当同一个页面存在多个模态框时,还需要手动管理每一个的状态,包括不能同时展示多个、控制的状态不能互相影响、设置状态的顺序等。再加上新增的这些状态如果与主组件有交互,将直接提升代码的维护难度。

可以说一个组件维护的状态越多,它的维护成本自然也会增多。

性能问题大,容易引发业务代码、弹层内重复渲染

每次弹窗组件状态变更,都会导致页面所有组件重新渲染

由于所有状态都维护在主组件,任何变动都将导致整个页面的重新渲染:

  • 主组件的状态更新引起所有子组件的重新渲染(即使 visible 是 false)
  • 子组件的状态更新也会同步到主组件,主组件整体重新渲染

由于 visible 只控制 Modal,而不控制 Modal 的子组件,所以即使 visible 为 false,Modal 的子组件依然可能影响性能。

1
2
3
tsx复制代码<Modal visible={visible}>
<LargeComponent />
</Modal>

逻辑割裂,弹层内外的逻辑联动性差

模态框从交互上创建了独立的工作流,也从代码逻辑上带来了割裂感

在实际开发中,开发者会将较为复杂或者可复用的 Modal 内容组件进行封装,比如 ApproveForm,但是封装后就会面临一个问题,即 Modal 和 ApproveForm 没有良好的通信机制。

比如点击 Modal 提交按钮,如何获取 ApproveForm 的表单状态?同时,visible 属性只是控制 Modal 的,在不额外控制的情况下,Modal 的生命周期和 ApproveForm 的生命周期是独立的:

  • ApproveForm 默认会执行初始化逻辑,即使此时 visible 还是 false
  • ApproveForm 永远不会走到销毁状态,无法在关闭弹窗的时候清除内部状态

优雅体操管理

牛刀小试

状态多?那就合并!

通过将一个弹窗所需的相关状态合并到对象中,既直接减少了状态量,同时也可避免一个个设置状态可能带来的额外理解成本,降低误操作的可能性。

1
2
3
4
5
6
7
8
9
tsx复制代码const [editModalInfo, setEditModalInfo] = useState({ 
visible: false,
posting: false,
record: {},
})

<Modal visible={editModalInfo.visible} {...}>
<Edit detail={editModalInfo.record} {...} />
</Modal>

逻辑割裂?还是合并!!

既然 Modal 组件与内容组件通信困难,不如直接将他们合并封装为一个组件,这总没问题了吧~

1
2
3
4
5
6
7
8
9
tsx复制代码const [editModalInfo, setEditModalInfo] = useState({ 
visible: false,
posting: false,
record: {},
})

const handleOk = () => {...}

<EditModal visible={editModalInfo.visible} record={editModalInfo.record} onOk={handleOk} {...} />

还可以把 posting 等内部属性移到 EditModal 组件内部管理,如此主组件又少维护了一个状态。

1
2
3
4
5
6
7
8
9
10
11
tsx复制代码// EditModal.tsx
const handleOk = () => {
// 先处理内部逻辑,再调用传入的onOk
setPosting(true)
// do something
await props.onOk(...)

setPosting(false)
}

<Modal {...} onOk={handleOk} />

封装为 EditModal 之后,前面说的生命周期的问题就会再度出现,即 EditModal 的 visible 为 false 时,依然会执行初始化逻辑,但是通常这个时候一些必填参数是拿不到的-通常再 visible 为 true 的时候一起传入,同样还会出现性能问题,或者弹窗关闭时无法清除状态的问题。

这种情况下,可以加个 HOC,直接在 visible 为 false 时销毁这个业务弹窗,轻松解决这些问题。

1
2
3
4
5
6
7
8
9
tsx复制代码// visible改变时直接销毁组件,不需要维护生命周期
export const oneTimeHoc = <Props extends Record<string, any>>(Component: React.FC<Props>) => {
return (props: Props & { visible?: boolean }) => {
return props?.visible !== false ? <Component {...props} /> : null
}
}

// EditModal.tsx
export default oneTimeHoc(EditModal) // 此时EditModal的入参需要移除visible

还复杂?那就继续合并!!!

如果有多个弹窗组件,那么还是需要维护多份xxxModalInfo状态以及实例化多个XxxModal组件,有几个弹窗就得维护几组状态,复杂度仍然很高,怎么办?那就加个中间层!

新增 ActionModal 链接主组件与对应的多个业务 Modal,这样主组件只需要维护一个大状态-一般情况下同一时间只会存在一个激活的 Modal,通过 ActionModal 再来做一层转发,成功将代码复杂度分散,从而降低主组件的维护复杂度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typescript复制代码// index.tsx
const [modalInfo, setModalInfo] = useState({
type: '', // edit | update | approve
visible: false,
modalProps: {},
})

return <>
<ActionModal modalInfo={modalInfo} />
</>

// ActionModal.tsx
const { type, visible, modalProps } = props

if (!visible) return null
return <>
{type === 'edit' && <EditModal {...modalProps} />
{type === 'update' && <UpdateModal {...modalProps} />
</>

小结

以上可以看作我们日常开发中解决问题的常见手段,确实能解决部分问题,但是显得有些治标不治本,不管怎么样,最终在主组件中依然依然需要管理弹窗的 visible 状态。

其实在多数场景下,都是由用户交互(如点击按钮)才唤起弹窗,那是否可以进一步,把交互元素(如按钮)与弹窗组件封装到一起,设计一种更为定制化的解决方案。

进阶技巧

初春 - Trigger 封装

对于主页面需要管理 visible 的问题,可通过cloneElement对指定元素进行拓展,屏蔽主页面对visible的感知。

实现思路比较简单,主要给传入的元素加个onClick属性,用来控制visible展示,然后在onOk的时候控制visible关闭。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
tsx复制代码// TriggerModal.tsx
const { children, trigger } = props
const [visible, setVisible] = useState(false)

const onClick = () => {
setVisible(true)
}

const handleOk = async () => {
// do something
setVisible(false)
}

return <>
{React.cloneElement(trigger, { onClick })}

<Modal {...} visible={visible} onOk={handleOk} />
</>

// index.tsx
return <>
<TriggerModal trigger={<Button />}> // 仅拓展Modal
{...}
</TriggerModal>
// 封装业务逻辑:EditModal = TriggerModal + 业务逻辑
// <EditModal trigger={<Button />} />
</>

优点:结合触发的元素一起封装,逻辑更内聚,适合特定业务场景(如审批按钮)

缺点:使用限制较大,不是通用的解决方案

半夏 - Ref 管理

对于在父组件中操作子组件状态这种事情,我们自然而然的就会想到使用 ref,下面就让我们来看看要怎么用 ref 实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
tsx复制代码// EditModal.tsx
const [visible, setVisible] = useState(false)
const modalPropsRef = useRef({})

useImperativeHandle(ref, {
open: (props) => {
modalPropsRef.current = props
setVisible(true)
},
close: () => setVisible(false),
})

return <Modal {...} visible={visible} onOK={modalPropsRef.current?.onOk} />

// index.tsx
const editModalRef = useRef(null)

editModalRef.current.open(props)
editModalRef.current.close()

return <>
<EditModal {...} ref={editModalRef} />
</>

优点:简单好使,理解成本低

缺点:限定了父子组件的实现逻辑以及调用方式,用起来不够优雅

秋实 - Hook 调用

在方案三ref的基础上,优化调用方式,从ref.current.open优化成hook返回的函数调用,即Modal.useModal的返回值modal.open,各组件库已经提供modal.confirm等函数,但是没有open,我们可以简单封装一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
tsx复制代码// useNextModal.tsx 随便起的名字,别在意
const [modal, context] = Modal.useModal()
const [visible, setVisible] = useState(false)
const modalPropsRef = useRef({})

const nextModal = {
// 已有,直接用
confirm: (props) => {
return modal.confirm(props)
},
// 自行封装
open: (props) => {
modalPropsRef.current = props
setVisible(true)
},
}

const modalRender = () => {
if (!visible) return null
return <Modal {...modalPropsRef.current} visible={visible} />
}

return { nextModal, context, content: modalRender() }

// index.tsx
const { nextModal, context, content } = useNextModal()

nextModal.open(...)
nextModal.confirm(...)

return <>
{context} // modal.confirm的上下文
{content} // modal.open的dom
</>

优点:简单好使,调用方式更直接

缺点:仅适合较简单的场景;hook返回了 DOM,有点争议

不返回 DOM 的 Hook

1
2
3
4
5
6
7
javascript复制代码const { formProps, modalProps } = useFormModal()

return <>
<Modal {...modalProps}>
<Form {...formProps} />
</Modal>
</>

瑞雪 - Modal 与 Form 完美结合

在中后台场景中,经常遇到弹窗与表单结合的功能,此时除了基础的弹窗管理之外,需要额外考虑表单管理的问题。以 antd 的 form 为例,我们通常会用以下方式之一组织代码:

  1. 主页面管理 form 实例,并通过参数传递给弹窗子组件
  2. 弹窗子组件内部维护 form 实例,通过回调将表单的值暴露出去

管理 form 实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
tsx复制代码// index.tsx
const [form] = Form.useForm()
const [visible, setVisible] = useState(false)

const handleOk = async () => {
const values = await form.validateFields()
await service.submit(values)
setVisible(false)
}

const handleOpen = () => {
form.setFieldsValue({ ... }) // 灵活控制form
setVisible(true)
}

return <>
<Button onClick={handleOpen}>Open</Button>
<EditModal visible={visible} onOk={handleOk} form={form} />
</>

// EditModal.tsx
const { form, visible, onOk } = props

return <Modal visible={visible} onOk={onOk}>{...}</Modal>

优点:使用简单,方便使用 form 管理弹窗组件内的表单

缺点:主页面需要多维护一个 form 实例,有一定复杂度

优化一下

在仍支持主页面控制 form 的前提下,将一部分逻辑放到弹窗组件内部处理,减少主页面的代码量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
tsx复制代码// index.tsx
const handleOk = async (values) => {
await service.submit(values)
setVisible(false)
}

// EditModal.tsx
const { form: outerForm, visible, onOk } = props
const [form] = Form.useForm(outerForm)

const handleOk = async () => {
const values = await form.validateFields() // 尽量把类似的逻辑放在弹窗组件内部
onOk?.(values)
}

return <Modal visible={visible} onOk={handleOk}>{...}</Modal>

最佳实践 - useFormModal

回到最开始举的例子,如果是现在,那我们就可以这样来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
tsx复制代码// index.tsx
const [form] = Form.useForm()
const { formModal, content } = useFormModal({ form }) // form参数可选

// 可以将业务逻辑再封装
const { handleCreate, handleDelete } = useOtherActions({ formModal, refreshList })

const handleEdit = (info) => {
formModal.open({
title: 'Edit',
content: <EditForm info={info} />, // 不需要传form,只用传组件需要的参数
onOk: async (values) => {
await service.approve(values)
message.success('edit success')
},
onCancel: () => console.log('click cancel'),
})
}

const handleApprove = (info) => {
formModal.confirm({
title: 'Approve',
content: <ApproveForm info={info} form={form} />, // 手动控制form
onOk: async (values) => {
await service.update(values)
message.success('approve success')
},
onCancel: () => console.log('click cancel'),
})
}

return <>
<Button onClick={handleCreate}>Create</Button>
<List>
{list.map(item) => {
return <List.Item info={item} onClick={handleEdit} ...>
{item.name}
</List.Item>
}}
</List>
{content} // 组件dom
</>

// EditForm.tsx
const { form, info } = props

return <>
<div>ID: {info.name}</div>
<Form.Item label="Address" reruired>
<Input placeholder="Please input something" />
</Form.Item>
</>

从代码中不难看出,前文所说的 3 个状态都已经从主组件抹去,其中 visible、posting 都自动由 useFormModal 进行管理,在业务开发中主组件/子组件都不需要关注,而 record 状态本身就只是中间状态,它只是为了在 Trigger <=> Modal 之间进行信息传递。

同时 form 实例默认也不再需要管理(但是支持手动管理),开发者主需要关系具体的业务逻辑:比如表单元素、提交接口、刷新列表等具体动作。

如此一来,可以说是完美的解决了前文提到的所有问题。

总结

弹窗的管理本质还是状态的管理,本文从业务场景中常见的 Modal 组件出发,分析在日常开发中对于 Modal 状态管理的“三宗罪”:状态管理复杂、容易引发性能问题、主子组件通信难,并结合开发经验给出了一些优化技巧。

此外还讨论了针对具体业务场景的定制化解决方案,通过 Trigger 封装、Ref 管理、Hook 调用等技巧,尽量从根本上去除 Modal 组件对主组件代码的状态带来的管理难题。

最终在弹窗内使用表单的场景,参考 Modal.useModal,进一步封装了 useFormModal,在贴合开发者心智的前提下,定向解决了该场景下状态管理困难的问题。

希望本文对大家有所帮助,欢迎留言讨论~

参考资料

  • React 实战 - 如何更优雅的使用 Antd 的 Modal 组件
  • sunflower-antd
  • refine-antd
  • withFormModal
+ [antd v4 Form 使用心得](https://zhuanlan.zhihu.com/p/375753910)
+ [如何优雅的对 Form.Item 的 children 增加 before、after](https://zhuanlan.zhihu.com/p/422752055)
  • Modal.confirm 违反了 React 的模式吗?

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

如何做代码Review?

发表于 2023-12-20

为什么要做代码Review?

  • 提高代码质量,提升自身水平
  • 及早发现潜在缺陷与Bug,,降低事故成本
  • 促进团队内部知识共享,提高团队整体水平
  • 保证项目组人员的良好沟通
  • 避免开发人员犯一些很常见,很普遍的错误

不要嫌麻烦和浪费时间,其实只要坚持下来,团队收益会非常大。

  1. 添加必要的注释

其实,写代码的时候,没有必要写太多的注释,因为好的方法名、变量名,就是最好的注释。以下就是总结的一些注释规范:

  • 所有的类都必须添加创建者和创建日期,以及简单的注释描述
  • 方法内部的复杂业务逻辑或者算法,需要添加清楚的注释
  • 一般情况下,注释描述类、方法、变量的作用
  • 任何需要提醒的警告或TODO,也要注释清楚
  • 如果是注释一行代码的,就用//;如果注释代码块或者接口方法的,有多行/* **/
  • 一块代码逻辑如果你站在一个陌生人的角度去看,第一遍看不懂的话,就需要添加注释了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码/**
 * @author didi
 * @date 2023/04/22 5:20 PM
 * @desc  实现类,用于demo展示
 */
public class DidiClass {
 
    /**
     * 这它将两个价格整数相加并返回结果。
     * 
     * @param x 第一个整数
     * @param y 第二个整数
     * @return 两个整数的和
     */
    public int sellTianLuo(int x, int y) {
        return x + y;
    }
}

2.日志打印规范

日志是快速定位问题的好帮手,是撕逼和甩锅的利器!打印好日志非常重要。如果代码评审的时候,这些日志规范没遵守,就需要修改:

  • 日志级别选择不对。常见的日志级别有error、warn、info、debug四种,不要反手就是info哈
  • 日志没打印出调用方法的入参和响应结果,尤其是跨系统调用的时候。
  • 业务日志没包含关键参数,如userId,bizSeq等等,不方便问题排查
  • 如果日志包含关键信息,比如手机号、身份证等,需要脱敏处理
  • 一些不符合预期的情况,如一些未知异常(数据库的数据异常等),又或者不符合业务预期的特殊场
  1. 命名规范

Java代码的命名应该清晰、简洁和易于理解。我们代码评审的时候,要注意是否有命名不规范,不清晰的代码。下面是一些命名规范的建议:

  • 类和接口应该使用首字母大写的驼峰命名法
  • 方法和变量应该使用小写的驼峰命名法
  • 常量应该使用全大写字母和下划线
  • 开发者是不是选择易于理解的名称给变量、类和方法进行命名

4.参数校验

我们代码评审的时候,要注意参数是否都做了校验,如userId非空检查、金额范围检查、userName长度校验等等。一般我们在处理业务逻辑的时候,要遵循先检查、后处理的原则。

如果你的数据库字段userName设置为varchar(16),对方传了一个32位的字符串过来,你不校验参数,插入数据库直接异常了。

很多bug都是因为没做参数校验造成的,这是代码评审重点关注的

  1. 判空处理

获取对象的属性时,都要判空处理。要不然很多时候会出现空指针异常。

1
2
3
typescript复制代码if(object!=null){
  String name = object.getName();
}

如果你要遍历列表,也需要判空

1
2
3
4
5
less复制代码  if (CollectionUtils.isNotEmpty(tianLuolist)) {
        for (TianLuo temp : tianLuolist) {
            //do something
        }
    }
  1. 异常处理规范

良好的异常处理可以确保代码的可靠性和可维护性。因此,异常处理也是代码评审的一项重要规范。以下是一些异常处理的建议:

  • 不要捕获通用的Exception异常,而应该尽可能捕获特定的异常
  • 在捕获异常时,应该记录异常信息以便于调试
  • 内部异常要确认最终的处理方式,避免未知异常当作失败处理。
  • 在finally块中释放资源,或者使用try-with-resource
  • 不要使用e.printStackTrace(),而是使用log打印。
  • catch了异常,要打印出具体的exception,否则无法更好定位问题
  • 捕获异常与抛出异常必须是完全匹配,或者捕获异常是抛异常的父类
  • 捕获到的异常,不能忽略它,要打印相对应的日志
  • 注意异常对你的代码层次结构的侵染(早发现早处理)
  • 自定义封装异常,不要丢弃原始异常的信息Throwable cause
  • 注意异常匹配的顺序,优先捕获具体的异常
  • 对外提供APi时,要提供对应的错误码
  • 系统内部应该抛出有业务含义的自定义异常,而不是直接抛出RuntimeException,或者直接抛出Exception\Throwable。
  1. 并发控制规范

  • 在使用并发集合时,应该注意它们的线程安全性和并发性能,如ConcurrentHashMap是线性安全的,HashMap就是非线性安全的
  • 乐观锁,悲观锁防止数据库并发.乐观锁一般用版本号version控制,悲观锁一般用select …for update
  • 如果是单实例的多线程并发处理,一般通过Java锁机制,比如sychronized ,reentrantlock
  • 如果是同一集群的多线程并发处理,可以用Redis分布式锁或者走zookeeper
  • 如果是跨集群的多线程并发处理,则考虑数据库实现的分布式锁。
  1. 单元测试规范

  • 测试类的命名,一般以测试的类+Test,如:CalculatorTest.
  • 测试方法的命名,一般以test开头+ 测试的方法,如testAdd.
  • 单测行覆盖率一般要求大于75%.
  • 单测一般要求包含主流程用例、参数边界值等校验用例
  • 单测一般也要求包含中间件访问超时、返回空、等异常的用例,比如访问数据库或者Redis异常.
  • 单测用例要求包含并发、防重、幂等等用例.
  1. 代码格式规范

良好的代码格式,可以使代码更容易阅读和理解。下面是一些常见的代码格式化建议:

  • 缩进使用四个空格
  • 代码块使用花括号分隔
  • 每行不超过80个字符
  • 每个方法应该按照特定的顺序排列,例如:类变量、实例变量、构造函数、公共方法、私有方法等。
  1. 接口兼容性

代码评审的时候,要重点关注是否考虑到了接口的兼容性.因为很多bug都是因为修改了对外旧接口,但是却不做兼容导致的。关键这个问题多数是比较严重的,可能直接导致系统发版失败的

所以,如果你的需求是在原来接口上修改,尤其这个接口是对外提供服务的话,一定要考虑接口兼容。举个例子吧,比如dubbo接口,原本是只接收A,B参数,现在你加了一个参数C,就可以考虑这样处理:

1
2
3
4
5
6
7
8
9
10
javascript复制代码//老接口
void oldService(A,B){
  //兼容新接口,传个null代替C
  newService(A,B,null);
}

//新接口,暂时不能删掉老接口,需要做兼容。
void newService(A,B,C){
  ...
}
  1. 程序逻辑是否清晰,主次是否够分明

代码评审的时候,要关注程序逻辑是否清晰。比如,你的一个注册接口,有参数校验、判断用户是否已经注册、插入用户记录、发送注册成功通知等功能。

如果你把所有所有功能代码塞到一个方法里面,程序逻辑就不清晰,主次不够分明,反例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
ini复制代码 public Response registerUser(String userName, String password, String email) {

        if (userName == null || StringUtils.isEmpty(userName)) {
          log.info("用户名不能为空!");
            throw new BizException();
        }

        if (password == null || password.length() < 6) {
            log.info("密码长度不能少于6位!");
            throw new BizException();
        }

        if (email == null || StringUtils.isEmpty(email) || !email.contains("@")) {
            log.info("邮箱格式不正确!");
            throw new BizException();
        }

        Response response = new Response();
        UserInfo userInfo = userService.queryUserInfoByUsername();
        if (Objects.nonNull(userInfo)) {
            response.setCode(0);
            response.setMsg("注册成功");
            return response;
        }


        UserInfo addUserInfo = new UserInfo();
        addUserInfo.setUserName(userName);
        addUserInfo.setPassword(password);
        addUserInfo.setEmail(email);
        userService.addUserInfo(addUserInfo);

        MessageDo messageDo = new MessageDo();
        messageDo.setUserName(userName);
        messageDo.setEmail(email);
        messageDo.setContent("注册成功");
        messageService.sendMsg(messageDo);

        response.setCode(0);
        response.setMsg("注册成功");
        return response;
    }

其实,以上这块代码,主次不够分明的点:参数校验就占registerUser方法很大一部分。正例可以划分主次,抽一下小函数,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
scss复制代码    public Response registerUser(String userName, String password, String email) {

        //检查参数
        checkRegisterParam(userName, password, email);
        //检查用户是否已经存在
        if (checkUserInfoExist(userName)) {
            Response response = new Response();
            response.setCode(0);
            response.setMsg("注册成功");
            return response;
        }

        //插入用户
        addUser(userName, password, email);
        sendMsgOfRegister(userName, email);

        //构造注册成功报文
        Response response = new Response();
        response.setCode(0);
        response.setMsg("注册成功");
        return response;
    }

    private void sendMsgOfRegister(String userName, String email) {
        MessageDo messageDo = new MessageDo();
        messageDo.setUserName(userName);
        messageDo.setEmail(email);
        messageDo.setContent("注册成功");
        messageService.sendMsg(messageDo);
    }

    private void addUser(String userName, String password, String email) {
        UserInfo addUserInfo = new UserInfo();
        addUserInfo.setUserName(userName);
        addUserInfo.setPassword(password);
        addUserInfo.setEmail(email);
        userService.addUserInfo(addUserInfo);
    }

    private boolean checkUserInfoExist(String userName) {
        UserInfo userInfo = userService.queryUserInfoByUsername();
        if (Objects.nonNull(userInfo)) {
            return true;
        }
        return false;
    }

    private void checkRegisterParam(String userName, String password, String email) {
        if (userName == null || StringUtils.isEmpty(userName)) {
            log.info("用户名不能为空!");
            throw new BizException();
        }

        if (password == null || password.length() < 6) {
            log.info("密码长度不能少于6位!");
            throw new BizException();
        }

        if (email == null || StringUtils.isEmpty(email) || !email.contains("@")) {
            log.info("邮箱格式不正确!");
            throw new BizException();
        } 
    }
  1. 安全规范

代码评审,也非常有必要评审代码是否存在安全性问题。比如:

  • 输入校验:应该始终对任何来自外部的输入数据进行校验,以确保它们符合预期并且不会对系统造成伤害。校验应该包括检查数据的类型、大小和格式。
  • 防范SQL注入攻击:在使用SQL查询时,应该始终使用参数化查询或预处理语句,以防止SQL注入攻击。
  • 防范跨站脚本攻击(XSS): 在Web应用程序中,应该始终对输入的HTML、JavaScript和CSS进行校验,并转义特殊字符,以防止XSS攻击。
  • 避免敏感信息泄露: 敏感信息(如密码、密钥、会话ID等)应该在传输和存储时进行加密,以防止被未经授权的人访问。同时,应该避免在日志、调试信息或错误消息中泄露敏感信息。
  • 防范跨站请求伪造(CSRF): 应该为所有敏感操作(如更改密码、删除数据等)添加CSRF令牌,以防止未经授权的人员执行这些操作。
  • 防范安全漏洞: 应该使用安全性高的算法和协议(如HTTPS、TLS)来保护敏感数据的传输和存储,并定期对系统进行漏洞扫描和安全性审计。
  1. 事务控制规范

  • 一般推荐使用编程式事务,而不是一个注解 @Transactional的声明式事务。因为 @Transactional有很多场景,可能导致事务不生效
  • 事务范围要明确,数据库操作必须在事务作用范围内,如果是非数据库操作,尽量不要包含在事务内。
  • 不要在事务内进行远程调用(可能导致数据不一致,比如本地成功了,但是远程方法失败了,这时候需要用分布式事务解决方案)
  • 事务中避免处理太多数据,一些查询相关的操作,尽量放到事务之外(避免大事务问题)
  1. 幂等处理规范

什么是幂等? 计算机科学中,幂等表示一次和多次请求某一个资源应该具有同样的副作用,或者说,多次请求所产生的影响与一次请求执行的影响效果相同。

代码评审的时候,要关注接口是否考虑幂等。比如开户接口,多次请求过来的时候,需要先查一下该客户是否已经开过户,如果已经开户成功,直接返回开户成功的报文。如果还没开户,就先开户,再返回开户成功的报文。这就是幂等处理。

一般情况有这几种幂等处理方案:

  • select+insert+主键/唯一索引冲突
  • 直接insert + 主键/唯一索引冲突
  • 状态机幂等
  • 抽取防重表
  • token令牌
  • 悲观锁
  • 乐观锁
  • 分布式锁

幂等要求有个唯一标记,比如数据库防重表的一个业务唯一键。同时强调多次请求和一次请求所产生影响是一样的。

  1. 中间件注意事项 (数据库,redis)

代码评审的时候,如果用数据库、Redis、RocketMq等的中间件时,我们需要关注这些中间件的一些注意事项哈。

比如数据库:

  • 关注数据库连接池参数设置、超时参数设置是否合理
  • 避免循环调用数据库操作
  • 如果不分页,查询SQL时,如果条数不明确,是否加了limit限制限制
  • 数据库的返回是否判空处理
  • 数据库慢SQL是否有监控
  • 表结构更新是否做兼容,存量表数据是否涉及兼容问题考虑
  • 索引添加是否合理
  • 是否连表过多等等

比如Redis:

  • Redis的key使用是否规范
  • Redis 异常捕获以及处理逻辑是否合理
  • Redis连接池、超时参数设置是否合理
  • Redis 是否使用了有坑的那些命令,如hgetall、smember
  • 是否可能会存在缓存穿透、缓存雪奔、缓存击穿等问题
  1. 注意代码坏味道问题

理解几个常见的代码坏味道,大家代码评审的时候,需要关注一些点:

  • 大量重复代码(抽公用方法,设计模式)
  • 方法参数过多(可封装成一个DTO对象)
  • 方法过长(抽小函数)
  • 判断条件太多(优化if…else)
  • 不处理没用的代码(没用的import)
  • 避免过度设计
  1. 远程调用

远程调用是代码评审重点关注的一栏,比如:

  • 不要把超时当作失败处理: 远程调用可能会失败,比如网络中断、超时等等。开发者需要注意远程调用返回的错误码,除非是明确的失败,如果仅仅是超时等问题,不能当作失败处理!而是应该发起查询,确认是否成功,再做处理。
  • 异常处理:远程调用可能会抛出异常,例如由于服务端错误或请求格式不正确等。因此,开发人员需要确保能够捕获和处理这些异常,以避免系统崩溃或数据丢失。
  • 网络安全:由于远程调用涉及网络通信,因此开发人员需要考虑网络安全的问题,例如数据加密、认证、访问控制等。尽可能使用安全的协议,例如HTTPS 或 SSL/TLS。
  • 服务质量:远程调用可能会影响系统的性能和可用性。因此,开发人员需要确保服务的质量,例如避免过度使用远程调用、优化数据传输、实现负载均衡等。
  • 版本兼容:由于远程调用涉及不同的进程或计算机之间的通信,因此开发人员需要注意服务端和客户端之间的版本兼容性。尽可能使用相同的接口和数据格式,避免出现不兼容的情况。
  • 尽量避免for循环远程调用: 尽量避免for循环远程调用,而应该考虑实现了批量功能的接口。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

手把手带你入门 Threejs Shader 系列(六)

发表于 2023-12-20

本系列教程的代码将开源到该仓库,前几篇文章的代码也会陆续补上,欢迎大家 Star:github.com/DesertsX/th…

本文的代码也同时放到了 Codepen,欢迎学习:codepen.io/GuLiu/pen/r…

此外,之前和之后的所有文章的例子都将更新到这里,方便大家和古柳一起见证本系列内容的不断壮大与完善过程 www.canva.com/design/DAF3…

系列文章

「手把手带你入门 Three.js Shader 系列」目录如下:

  • 「断更19个月,携 Three.js Shader 归来!(上) - 牛衣古柳 - 20230416」
  • 「断更19个月,携 Three.js Shader 归来!(下) - 牛衣古柳 - 20230421」
  • 「手把手带你入门 Three.js Shader 系列(一) - 牛衣古柳 - 20230515」
  • 「手把手带你入门 Three.js Shader 系列(二) - 牛衣古柳 - 20230716」
  • 「手把手带你入门 Three.js Shader 系列(三) - 牛衣古柳 - 20230725」
  • 「手把手带你入门 Three.js Shader 系列(四) - 牛衣古柳 - 20231121」
  • 「手把手带你入门 Three.js Shader 系列(五) - 牛衣古柳 - 20231126」

正文

通过第一阶段片元着色器的学习相信大家对 shader 语言有入门的感觉了吧。前五篇文章的例子配图多达120+,古柳希望用尽可能多的图帮大家克服 shader 学习时存在的抽象难理解等障碍。

看到新加上的群友边学习边一个个例子地敲还是很欣慰的,不妄古柳每篇文章几千字费心地写、用心地搭配示例图。大家觉得本系列文章写得不错的话,希望能多多点赞评论支持,既是对古柳持续输出优质原创教程的鼓励,也方便推送给更多感兴趣的人。

闲言少叙,书归正传,让古柳带大家开启第二阶段的学习,正式上手顶点着色器,补全之前缺失的另一半,看看能做出多么酷炫的效果。

显示球体

前几篇文章主要用 PlaneGeometry 举例子,这篇文章我们换成 SphereGeometry。单色的球体看着像圆圈,开启线框模式设置 wireframe:true 后稍显立体,同时能看到横纵交织的线段及线段相交处的顶点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
js复制代码const vertexShader = /* GLSL */ `
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

const fragmentShader = /* GLSL */ `
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}
`;

const geometry = new THREE.SphereGeometry(1, 32, 16);
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
},
vertexShader,
fragmentShader,
wireframe: true,
});

const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// let time = 0;
const clock = new THREE.Clock();
function render() {
// time += 0.05;
// material.uniforms.uTime.value += time;
const time = clock.getElapsedTime();
material.uniforms.uTime.value = time;
renderer.render(scene, camera);
requestAnimationFrame(render);
}

render();

借助顶点着色器我们就能对几何体上每个顶点的位置进行改变从而实现特殊的效果。

但在此之前,我们需要知道将三维空间里的物体显示到二维屏幕上需要通过 MVP 矩阵变换操作,即 Model 模型矩阵、View 视图矩阵和 Projection 投影矩阵(前俩者可以合并为 modelViewMatrix),因此在顶点着色器里这一行代码是必不可少的。

1
2
3
4
C#复制代码void main() {
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
// gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

背后原理非一两句可以解释清楚,感兴趣的朋友可以看看这几篇文章去了解。

  • 链接:从零开始学图形学:MVP Transformation
  • 链接:图形学:MVP变换概述
  • 链接:计算机图形学 5:齐次坐标与 MVP 矩阵变换

不过大家不用担心,古柳对图形学同样知之甚少,我们只需依样画葫芦地敲出这行代码,同时知道这里的 position 就是基于 Geometry 生成的3D物体其创建之初每个顶点的坐标,这些顶点以几何体自身中心为三维坐标原点,因而是每个模型/几何体的局部坐标空间。

例如前面我们创建出半径为1的球体,那么与x轴正轴相交的顶点其坐标就是(1,0,0),不管后续的 mesh 怎么平移旋转缩放,每个顶点的 position 值都是固定不变的,当然这些变换会以模型矩阵 modelMatrix 的形式作用到 position,然后才变到整个场景三维坐标原点的世界坐标空间……扯远了,既然不管物体在哪个地方 position 都是几何体自身的坐标,那么我们直接改变 position,就能改变几何体的形状。

球体放大

最简单的改变顶点坐标的操作可能就是放大缩小。对 position 数值乘以1.5进行放大,然后用新的顶点坐标 newPos 去进行 MVP 矩阵变换,得到的效果就是放大的球体。

1
2
3
4
C#复制代码void main() {
vec3 newPos = position * 1.5;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

球体大小动态变化

之前在片元着色器部分,我们可以通过传入 uTime 并借助 uv 绘制出半径动态变化的圆圈,同样在这里我们可以用 uTime 和 sin 函数使球体大小周期性动态变化。注意这里 sin 是负数也没事,球体上每个顶点都有关于中心原点对称的另一顶点存在,比如 (1,0,0) 乘以-1.0变成 (-1,0,0),后者也在球体上,反之亦然。每个顶点统一对掉下,对这里的效果而言问题不大。

1
2
3
4
5
6
C#复制代码uniform float uTime;

void main() {
vec3 newPos = position * sin(uTime);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

当然上面的效果直接通过设置 mesh 的 scale 就能实现,shader 所能实现的效果自然远不止如此,我们马上就会见识到。

1
2
3
4
5
js复制代码function render() {
const time = clock.getElapsedTime();
material.uniforms.uTime.value = time;
mesh.scale.setScalar(Math.sin(time));
}

顶点 y 坐标累加 sin 值

当每个顶点的变化不再步调一致、单调统一时,shader 的威力才开始显现。让我们换个方式使用 sin 函数,用每个顶点的 y 坐标计算 sin 值,然后累加回 y 坐标,此时形状类似纺锤体。

1
2
3
4
5
C#复制代码void main() {
vec3 newPos = position;
newPos.y += sin(position.y);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

我们对 y 坐标乘以不同的数值(如0.1、0.3、0.5、1.0、5.0等)来改变范围后再传给 sin 函数,此时形状越来越有趣,虽然我们不太好解释 sin 函数为何使球体变形出这样的形状,非要说点的话,可以把 sin 理解成波浪,那么不同 y 坐标上下偏移了不同波浪的幅度,当范围越大时重复的波浪也越多,图中往上或往下的起伏也越多……当然无法理解也没事,这些都不重要,我们可以自由发挥去改变参数,只要效果自己满意就行。

1
2
3
C#复制代码vec3 newPos = position;
// newPos.y += sin(position.y * 0.5);
newPos.y += sin(position.y * 4.0);

如果不想每次手动调整数值看效果,我们也可以用 GUI 去控制一个变量再通过 uniform 传入到顶点着色器来进行更新,后续文章会讲到,这里偷个懒,继续借助 sin 函数来将 uTime 变化到0.0到10.0再去改变y的值,这样就能动态直观的看到形状如何随数值变化而变化。

1
2
C#复制代码vec3 newPos = position;
newPos.y += sin(position.y * (sin(uTime) + 1.0) * 5.0);

当然我们也可以固定下数值,将 uTime 加到后面,动画效果同样很有趣。

1
2
C#复制代码vec3 newPos = position;
newPos.y += sin(position.y * 1.0 + uTime * 2.0);

xyz 坐标不同偏移

接着还能对 xyz 坐标进行不同程度的偏移,比如再用 z 坐标计算 sin 值对 x 坐标进行改变,并且 sin 里的参数也可以相应变化…这里可调整改变的地方和对应存在的可能性很多,留给大家自行探索,或许能发现更有趣的效果。

1
2
3
C#复制代码vec3 newPos = position;
newPos.y += sin(position.y * 1.0 + uTime * 2.0);
newPos.x += 0.8 * sin(position.z * 0.5 + uTime * 1.0);

sin 函数我们已经用过很多次,大家原本也很熟悉,不过实际作品中可能多个 sin、cos 一组合所产生的图形或视觉效果就不再那么简单直观好解释了,这一点不知道有人是否和古柳一样有同感。

简单强大的 noise 噪声函数

接下来介绍的 noise 噪声函数(Perlin Noise、Simplex Noise 等)可能有些人还没听说过,但其实用起来很简单,而且效果更强大。一言以蔽之借助 noise 函数能使相邻的点(一维、二维、三维的点都行)产生相近的数值,而不是 random 随机函数那种每个位置的数值都和附近无关的效果。

noise 函数不是内置函数但有现成的实现可以使用,我们谷歌搜索 glsl noise function,能在这个链接里找到很多实现。这里我们先演示在三维顶点坐标上使用 noise 的效果,因此先复制粘帖接收 vec3 格式参数的 cnoise() 到 main 函数之前然后进行使用。

  • 链接:gist.github.com/patriciogon…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
C#复制代码//	Classic Perlin 3D Noise 
// by Stefan Gustavson
vec4 permute(vec4 x){return mod(((x*34.0)+1.0)*x, 289.0);}
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159 - 0.85373472095314 * r;}
vec3 fade(vec3 t) {return t*t*t*(t*(t*6.0-15.0)+10.0);}

float cnoise(vec3 P){
vec3 Pi0 = floor(P); // Integer part for indexing
vec3 Pi1 = Pi0 + vec3(1.0); // Integer part + 1
Pi0 = mod(Pi0, 289.0);
Pi1 = mod(Pi1, 289.0);
vec3 Pf0 = fract(P); // Fractional part for interpolation
vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
vec4 iy = vec4(Pi0.yy, Pi1.yy);
vec4 iz0 = Pi0.zzzz;
vec4 iz1 = Pi1.zzzz;

vec4 ixy = permute(permute(ix) + iy);
vec4 ixy0 = permute(ixy + iz0);
vec4 ixy1 = permute(ixy + iz1);

vec4 gx0 = ixy0 / 7.0;
vec4 gy0 = fract(floor(gx0) / 7.0) - 0.5;
gx0 = fract(gx0);
vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
vec4 sz0 = step(gz0, vec4(0.0));
gx0 -= sz0 * (step(0.0, gx0) - 0.5);
gy0 -= sz0 * (step(0.0, gy0) - 0.5);

vec4 gx1 = ixy1 / 7.0;
vec4 gy1 = fract(floor(gx1) / 7.0) - 0.5;
gx1 = fract(gx1);
vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
vec4 sz1 = step(gz1, vec4(0.0));
gx1 -= sz1 * (step(0.0, gx1) - 0.5);
gy1 -= sz1 * (step(0.0, gy1) - 0.5);

vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);

vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
g000 *= norm0.x;
g010 *= norm0.y;
g100 *= norm0.z;
g110 *= norm0.w;
vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
g001 *= norm1.x;
g011 *= norm1.y;
g101 *= norm1.z;
g111 *= norm1.w;

float n000 = dot(g000, Pf0);
float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
float n111 = dot(g111, Pf1);

vec3 fade_xyz = fade(Pf0);
vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);
return 2.2 * n_xyz;
}

现在让我们用 noise 函数对球体上每个顶点生成一个0到1的数值,如果直接加到 position 上,会因为数值是正数且同时加到xyz三个分量上,导致每个顶点都往一个方向偏移,比如左侧顶点(-1,0,0)、右侧顶点(1,0,0)都是加上(0.1,0.1,0.1)。

1
2
3
4
5
6
7
8
9
C#复制代码float cnoise(vec3 P){
// ...
}

void main() {
vec3 newPos = position;
newPos += cnoise(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

这并不是我们想要的效果,我们希望每个顶点朝自己原本的方向去偏移,左侧的点往左,右侧的点往右,上方的点往上,下方的点往下……此时可以借助每个顶点自带的法线 normal 来达到这个目的。我们之前说过每个顶点自带的属性有 position、uv 和 normal,那么在这里用 normal (其为单位向量)表示偏移方向,用 noise 得到的数值表示偏移幅度,再累加到 position 上即可实现所需效果。

1
2
3
4
5
C#复制代码void main() {
vec3 newPos = position;
newPos += normal * cnoise(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

normal 作为颜色

上面的形状看着“有机”“自然”得多,但单色模式下总显得不够立体。我们可以把法线向量通过 varying 传递到片元着色器并设置成颜色,此时不同位置颜色不同,看着立体些。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
C#复制代码varying vec3 vNormal;

void main() {
vec3 newPos = position;
newPos += normal * cnoise(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);

vNormal = normal;
}

// Fragment Shader
varying vec3 vNormal;

void main() {
// gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
gl_FragColor = vec4(vNormal, 1.0);
}

再转动物体,立体感更明显。(当然我们会发现顶点偏移和物体转动后,法线还是原来的值没有自动更新,后续我们会讲如何在顶点着色器里更新法线。)

1
2
3
4
5
js复制代码function render() {
const time = clock.getElapsedTime();
material.uniforms.uTime.value = time;
mesh.rotation.y = time;
}

random vs noise

那么,使用 random 随机函数的效果又是怎样的?我们谷歌搜索 glsl 3d random function 从 shadertoy 上找到三维的随机函数,同样复制粘帖后,传入 position 得到每个顶点的随机值作为偏移量。可以看出上面 noise 的效果有更多平滑些的部分,因为邻近点的偏移数值或幅度相近,像是山脉一样有自然起伏,而 random 的效果与顶点是否邻近无关,任何位置都是一样的随机,因而骤变的情形更多。

  • 链接:www.shadertoy.com/view/WljBDh
1
2
3
4
5
6
7
8
9
10
11
C#复制代码// 3D Randomness
float random(vec3 pos){
return fract(sin(dot(pos, vec3(64.25375463, 23.27536534, 86.29678483))) * 59482.7542);
}

void main() {
vec3 newPos = position;
// newPos += normal * cnoise(position);
newPos += normal * random(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

增加几何体细分数

上面 random 效果下的几何体看着蛮有趣,我们可以增加球体的细分数(下面以2的倍数为参数进行设置不必非得如此)使得几何体上有更多的顶点可以被操控、被偏移,此时的效果也蛮漂亮的不是吗?

1
2
3
4
js复制代码// const geometry = new THREE.SphereGeometry(1, 32, 16);
// const geometry = new THREE.SphereGeometry(1, 64, 64);
// const geometry = new THREE.SphereGeometry(1, 128, 128);
const geometry = new THREE.SphereGeometry(1, 256, 256);

noise 改变 position 相邻范围

当在 256x256 细分数时,切换回 noise 效果却发现形状基本没变。

虽然古柳也觉得很奇怪,但我们可以通过给 position 乘以不同数值来达到改变形状的目的。为什么能生效呢?还记得我们说 noise 能对相邻的点产生相近的数值嘛,那么如何定义“相邻”这个概念呢,比如相邻的街道、相邻的城市、相邻的省份、相邻的国家……都叫相邻却距离远近各不相同。同样的当细分数固定后,相邻 position 的距离也固定了,但我们剩以不同数值后,相当于范围距离也跟着变了,假入原本是相距0.1单位,可能就变成0.03、0.8、2.7等等,此时再传入到 noise 函数里效果就会不一样。如下图所示,当数值越小,所有球体上顶点都非常接近,此时所有偏移数值都变化不大,就越接近球体本身;当数值越大,每个顶点距离越远,越不相邻,数值越不相关,也就越接近 random 时的效果。

1
2
3
4
5
6
7
8
C#复制代码void main() {
vec3 newPos = position;
// newPos += normal * random(position);
// newPos += normal * cnoise(position);
// newPos += normal * cnoise(position * 0.3);
newPos += normal * cnoise(position * 5.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

动态调整数值

同样这里可以用 GUI 控制参数,但古柳还是继续偷懒用 sin 函数来将 uTime 变化到0.0到8.0再去改变 position 范围,这样就能动态直观的看到形状如何随数值变化而变化。

1
2
3
4
5
6
7
8
C#复制代码void main() {
vec3 newPos = position;
// newPos += normal * random(position);
// newPos += normal * cnoise(position);
// newPos += normal * cnoise(position * 5.0);
newPos += normal * cnoise(position * (sin(uTime) + 1.0) * 4.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

小结

本篇文章古柳教大家如何在顶点着色器里用 sin、random、noise 等函数对顶点坐标进行偏移从而改变几何体形状。大家也能发现除去文章讲解所需的那些步骤以及拷贝一些现成的函数,实际上我们只需几行代码就能通过 shader 做出有趣酷炫的效果。如果说前几篇文章里片元着色器的例子还比较平淡枯燥,那么相信本文的例子能让初学者再一次领会到 shader 的魅力吧!

当然咱们的 Three.js Shader 之旅还只是开了个头,更多酷炫例子后续会依次奉上,古柳一定不会让大家失望(也希望大家多多点赞等支持让古柳更有动力高质量输出内容)。

而当我们实现了上述效果,又该如何更进一步地用不同偏移值去作为颜色,而非用单色或法线 normal。大家别着急,下一篇文章古柳就会教大家如何实现出这个更酷炫的效果。

而这些是古柳最初入坑 Shader 时从 Pepyaka 这个作品的实现里学到的,现在终于也快要教给大家了,敬请期待!

  • 链接:www.bilibili.com/video/BV1UZ…

最后也别忘了好好复习本文内容。

照例

如果你喜欢本文内容,欢迎以各种方式支持,这也是对古柳输出教程的一种正向鼓励!

最后欢迎加入「可视化交流群」,进群多多交流,对本文任何地方有疑惑的可以群里提问。加古柳微信:xiaoaizhj,备注「可视化加群」即可。

欢迎关注古柳的公众号「牛衣古柳」,并设置星标,以便第一时间收到更新。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

⚡️卡顿减少 95% — 记一次React性能优化实践(性能

发表于 2023-12-20

这是我们团队号工程化系列的第二篇文章,将会来讲讲渲染性能优化。 全系列文章如下,欢迎和大家一同交流讨论:

  • 字节三年,谈谈一线团队如何搞工程化一(全景篇)

团队尚有HC,感兴趣的小伙伴可以私信~(注明期望岗位城市:北京、上海、杭州)


什么?今天我被 Leader 拉进小黑屋了!

“有很多用户吐槽咱的页面太卡了啊,这个情况你了解不?”,吓的我立马答道 “不可能,绝对不可能,我开发的时候可是一点都不卡…”

“你自己过来看看,你看这输入框,只要我输入速度一变快,整个页面都肉眼可见的变卡了! ”

“啊这,确…确实”,铁证如山 ,我一时无言以对,“怎么会这样呢,唉不对,这个页面配置下发的表单项也太多了吧,之前测试时可没有这么多…”

Leader 开始触发李氏连招,“表单项多就是它卡的理由吗,再说哪里多了?这么多年了中后台项目的表单项都是这么多,不要睁着眼睛乱说,中后台想做好很难的……有的时候找找自己原因,这么多年了技术水平涨没涨,有没有在认真工作?….”

“好好,我再去研究研究,想想办法……”


(以上对话纯属艺术加工,咱团队的 Leader 还是很 Nice 的 :)

前言

对于前端开发攻城狮们来说,性能优化是一个永恒的话题。随着前端需求复杂度的不断升高,在项目中想始终保持着良好的性能也逐渐成为了一个有挑战的事情。本文会首先简述我们在 React 项目中常用的一些性能优化方式,并将从笔者近期参与的一个实际业务需求出发,讲述我在 React 中后台场景下所遇到性能问题排查时的心路历程。

本文标题中的卡顿减少,指的是对项目执行输入操作后,所花费的单帧渲染耗时的降低比例。 单帧渲染耗时通过 React Developer Tools 插件测得。一般来说浏览器运行帧率为 60hz,因此对于帧率要求较高的场景如内容输入后的响应、动画等,需要尽可能的将单帧耗时控制在 16.6 ms(1s / 60)以内,防止 JS 长期霸占主线程,用户才能完全感知不到系统的卡顿。同时,这方面的用户体验正是 Interaction to Next Paint (INP) 指标所衡量的。良好的 INP 指标意味着页面能够始终如一、迅捷可靠地响应用户的输入。


React 如何做性能优化
=============

事实上,React 框架本身为了追求高性能,已经做了非常多的努力。

UI 更新需要进行昂贵的 DOM 操作,为此引入了虚拟 DOM,减少了实际 DOM 操作的次数,同时也为声明式、基于状态驱动的 UI 编程方式打开了大门。

为了追求最小化的 DOM 操作范围,提出了高效的 Diff 算法(Reconciliation),通过抽象出两个假设,将比较两棵 DOM 树差异的复杂度缩小到 O(n)。

从 React 16 开始引入了 Fiber 架构,“这是一种重新实现 React 核心算法的方式”,基于 JS 的Generator函数,使用协程的概念使得可中断的渲染成为可能,同时给不同的任务分配了优先级,从而在更小的粒度调度和优化 React 应用程序的渲染过程。

我们该怎么做?

站在了巨人的肩膀上,我们所能做的,便是在不添乱的前提下,帮助React更快、更高效的完成遍历渲染的过程,使更新链路尽可能的短的走完。 这也是 React 性能优化的核心目标。

一、控制组件重渲染的波及范围,只让该更新的更新,不该更新的不更新,灵活运用React.memo 跳过重渲染。

  • 在 React 的默认行为中,一个组件触发更新,那么它会递归遍历其所有子组件,生成新的虚拟 DOM 树,最后再进行 Diff,决定哪些需要提交到真实的 DOM 中。
  • 尽管最后更新的实际 DOM 节点并不多,但组件调用和 Diff 的成本也是昂贵的。当变更的组件层级较高,或者组件内部逻辑复杂,将会导致一些性能问题。

二、避免组件入参的不必要变更,在使用 memo 对组件进行缓存后,默认情况下,React 将使用 Object.is 来浅比较每个 prop。这就意味着当存在数组、对象、函数等形式的入参时,需要格外注意,否则我们的 Memo 可能永远不会生效。

  • 对于需要生成 数组 、对象等场景,可使用 useMemo 来跳过昂贵计算的重复生成,在不必要更新时保持对象的引用不变。尽量避免在 JSX 内直接写字面量来创建新的对象、数组。
  • 对于需向子组件传递 回调函数 等场景,可使用 useCallback 来缓存所需传入的回调函数,使得此函数在父组件重渲染时不会被重新生成,保持函数引用的统一。尽量规避在 JSX 内传参时写内连函数,这会在每次渲染时创建一个新函数。
  • 对于使用了 Context 上下文 的场景,向 Provider 传递 Value 时也需要格外注意。如果 Value 是一个对象类型,可以将其用 useMemo 包裹,否则所有依赖此上下文的子组件都将随着 Provider 的父组件的重渲染而渲染,哪怕此子组件已经被 memo 包裹。

三、避免频繁、重复、无意义的 setState, 调用 setState 即意味着即将触发重渲染,递归调用所有子组件的运行和 Diff 成本可能是昂贵的。

  • 和页面展示/更新无关的数据,不维护在 State 中。 如果这个变量都不会在界面上显示,或者说,不会因为这个变量的改变而触发更新,可以考虑维护不在 State 中维护,例如,像用作计数器之类的变量,可以使用 useRef 存储。
  • 合并 state,减少频繁 setState 的场景。 例如,在异步获取多个接口数据的场景中,相比各个接口请求完成后设置独立的 state,可以等待他们都请求完成之后,合并设置到一个 state 中。这样可以有效减少重渲染次数,毕竟中间设置的 state 引发的重渲染是没有意义的。

以上三点总结,是我们在日常开发迭代中,最常用且往往都能获得高收益的性能优化手段。

当然,React 性能优化方式还有很多,社区里也有较为充分的沉淀,我在这里就不多赘述了,推荐两篇文章,感兴趣的读者可以延伸阅读。

  • React 官方文档 性能优化篇 - 关于性能优化这块最官方的介绍
  • Twitter 分享 如何构建高性能的 React 应用

从一个实际业务需求出发

如何优化 React 项目的性能,方向是清晰的,但路线是曲折的。方法论我们是略知一二了,实际的需求场景可是五花八门。接下来我将从近期参与的一个实际业务需求出发,记录下我在这个项目中遇到性能问题时,发现问题、解决问题的心路历程。

优化效果

优化方式 优化前耗时 优化后耗时 提升百分比
通过Memo阻断整个表单部分重渲染问题,useCallback包裹回调方法。 195ms 88.3ms 54.71%
通过自行实现简易Diff拦截,减少不必要的setState调用。 88.3ms 25.9ms 70.6%
复杂计算使用useMemo缓存结果,抽出简单的纯函数组件,使用Memo包裹 25.9ms 9.5ms 63.3%
总计提升 195ms 9.5ms 95.14%

需求背景

首先咱们先来简单介绍下这个业务需求的大概背景。

为了更好的支持中后台业务上的各种规则配置、预览等需求,需要开发一套模版字段编辑器组件。 简单来说,就是来让用户在左边的表单部分编辑相关字段的值,并在右边部分实时展示对应生成的规则结果。


通过这样的交互可以让用户更好的理解配置的内容,明确当前编辑的字段在整体复杂规则中所起到的位置和作用,并对最后生成的规则进行实时预览。

在实现上,为了降低各业务方的接入和维护成本,提升可扩展性,编辑器左侧的表单部分和右侧的模版部分均采用配置化动态下发的形式。

左侧的表单编辑部分,与后端同学一同定义了一套描述表单的 JSON Schema 以实现配置的动态下发,前端对配置进行解析,渲染成实际的表单项,表单部分底层使用了 Antd 的 Form 来实现。

右侧的模版部分采用的是 HTML 格式,支持在 Html 中通过简单的标记语句标注来实现字段插空、循环遍历、判断、表单值之间的联动等功能,相当于在内部实现了一套简易的模版引擎,最终在展示时将解析成 React Element,以更方便的处理点击事件、样式变化等。

性能压测

作为一个通用组件,业务方在实际调用时,可能会以列表形式呈现,在一个页面中堆叠多个实例。因此,编辑器组件的性能表现就尤为关键。

一是不能让用户在编辑器内修改字段时、实时预览时感受到卡顿 ,保证编辑时的用户体验;

二是不能因为随着编辑器实例数量的增加,导致宿主页面出现卡顿的问题。

在按照常规思路完成了编辑器的实现之后,为了能更好的了解编辑器的性能表现,这里造了一个较为复杂的测试用例进行一个压力测试。

测试用例如下:

表单项 共计 90 项 (普通表单项 30 个,表格组件 20 行 * 每行 3 个表单项)

模版插空 共计 152 个 (普通插空 30 个,需循环遍历生成的语句共 3 段,共计遍历生成插空 122 个)

测得优化前性能表现:

聚焦某表单项(171.1ms) => 修改值(195.5ms) => 失焦(140.5ms)


虽然说这是一个较为极端的测试用例,一般来说业务实际的配置字段可能不会有这么多。但从结果上看,我们实现的编辑器在这种重表单场景下的性能表现还是很拉垮的,进一步的性能优化刻不容缓。

排查思路


回到当前的项目中,在使用 React Developer Tools 插件开启了渲染时高亮后,我们可以很清晰的看到,表单项中任意值的改变都会引发整个表单的重新渲染。通过 Profiler 功能录制操作过程,也可以发现,目前在编辑器的实现中,任何值的改变都是牵一发而动全身的,表单部分占了渲染耗时的大头。

  1. 表单重渲染问题

编辑器通过向 Form 传递 onFieldsChange 方法来收集表单的当前值和校验状态,在右侧模版内容进行展示。计划先通过简单的二分法大致定位问题所在,在注释掉 Form 的 onFieldsChange 方法以及给每个字段的 onFocus、onBlur 方法后,对表单执行相同操作的耗时回落到了 0.9ms。

这说明 Antd 的 Form 本身,在处理普通的输入项时的性能是很优秀的,内部做好了充分的 memo 优化,单个表单项改变时并不会引起整个表单的重渲染。

推测主要的性能瓶颈在于对表单值的收集、以及对模版的解析和处理上。

由于对编辑器来说,对表单状态变化的实时监听是必不可少的,先把 onFieldsChange 加回去,看看问题出在哪。



可以看到,在 onFieldsChange 添加后,我这会把 Form 的当前表单值更新到一个 state 中,也许是这个全局 state 的变化导致了整个 Form 的重渲染,每一个值的改变耗时达到了 148ms,其中表单重渲染的开销占了 73.3ms,尝试对这部分先行优化。

在当前实现的层级中 Form 与我们存储的 formValue state 同属一个层级,因此 state 的改变必然导致 Form 的再次渲染。


解决方案:

  1. 考虑对 Form 部分单独拆分组件,并使用 Memo 包裹。


2. 严格检查封装后 Form 组件的所有入参,尽量避免入参的变化,相关回调方法使用 callback 包裹,需要获取最新的 state 时,尽量使用 setState 的 function 形式,减少对外部 state 更新的直接依赖,造成方法引用的频繁改变

效果评估:

由于有效的阻断了每次表单项改变时的全表重渲染,优化效果是非常立竿见影的,渲染时长由 195ms 降至 88.3ms,提升了约 54.71%



2. 避免 State 的不必要更新


88.3ms 的每次更新耗时依然是远远不够的,这里注意到大部分的耗时都花在了最外层组件本身,66.7ms of 88.3ms,首先还是想在 onFieldsChange 方法内部继续寻找可能的优化点。

onFieldsChange 优化

我们可以先来看下原先 onFieldsChange 的实现,其实还是比较简单的,大致就是遍历 Form 中返回的 changedFields,然后在外部 State 对应的位置设置上新的值,最后通过浅拷贝设置一个全新的 State 对象。

由于需要尽量保持 onFieldsChange 方法的引用不变,因此此处使用 function 形式来更新 state。


存在问题:

  1. 每次设置 setState 会设置一个全新的 formValue 对象,formValue 是一个较为复杂的大对象,右侧的渲染部分全部由它生成,Diff 的压力全部交给了 React 的 Virtual DOM 来完成。
  2. 在测试时发现,Antd Form 的 onFieldsChange 在一个值更改、onBlur 时都可能会重复调用多次,例如下图场景,更改后触发了两次回调,value 值相同,errors 会在第二次调用时返回,如果不加拦截,也会带来不必要的状态更新。


解决方案

新增简易的 Diff 方法,当新值是简单值类型时,与原先值进行比较,如果完全一致,则拦截此次 setState。

由于我们希望 onFieldsChange 方法的引用不变,外层使用了 useCallback 进行包裹。因此我们这里使用了 function 的形式来设置新的 state,通过在最后返回原 state 对象的方式来拦截此次 setState。( 确实有点 Hack 但真的很管用 :)


优化效果评估

相当于我们在前置环节,更小的数据字段维度进行了更细粒度、成本更低的一个 Diff。通过输出 needUpdate 变量来观察,可以发现,有大量不必要的 setState 操作被拦截,可大幅减少后续的 Diff 成本


对相同输入项执行输入操作,外层组件的单次渲染时长再次由 88.3ms 降至 25.9ms,提升了约 70.6%,相较最开始的 195.5ms,已提升了 86.7%,主观感受输入的卡顿感明显减少。



3. 复杂计算使用 useMemo 缓存结果


一般浏览器运行的帧率是每秒 60 帧,想要完全不卡,我们需将每次渲染耗时控制在 16.6ms 以内 (1s/60) 。25.9ms 的成绩与我们的目标还有一定的差距,需要进一步探索原因。

最外层的结构还是比较简单的,猜测的性能开销大户主要就是这两个钩子

useFieldList 负责对后端下发的静态表单配置部分进行预处理和映射,生成表单所需要的配置项。

useTemplate 负责对模版进行解析,替换,生成模版的结果以及 React Element,这个钩子需要对表单值的变更进行实时监听。


useFieldList

尝试先从 useFieldList 入手,首先看它有没有性能问题。

这个组件内部的复杂计算,理论上只需要在配置变化时处理一次即可。如果说有不必要的重复计算,即可视为一个需要优化的问题。

通过打 log 输出排查,可以发现 FieldList 部分并没有随着表单值变化而重复计算,得出结论:先前在开发时已经注重了此处的 Memo,性能表现是符合预期的。


useTemplate

接下来便是重点解决 useTemplate 了

这个钩子其实是一个比较难啃的部分,因为它必然需要在每次表单值变化时,重新对模版进行解析、遍历、替换上新的 FormValue、生成新的 ReactElement。

如果加上防抖来减少生成次数,也会给用户造成变更值后预览展示不实时的感觉,反而影响了用户体验。

单纯从 JS 业务逻辑上来看,这段代码是必须需要每次更新的,每次计算也都最小依赖的 memo 住了,在这点上优化空间已经很小。


4. 纯函数组件使用 Memo 包裹


回到火焰图继续寻找线索,发现了一个有意思的情况:

如果是这个钩子的 js 本身耗时比较高,那应该归到这个钩子调用的父组件,也就是 TemplateFieldEditor 部分。事实上我们看这个节点确实有一定计算的耗时,但充其量不过 8.3ms。依然有 20ms 左右是被子组件消耗掉的。

在这里最后一个 rerender 的子组件,耗时 19.7ms,但本身只占用了 0.1ms 不到,但乍一看它并没有大量耗时的子组件存在了。

当 Devtool 宽度不足时,耗时较短的节点将会被省略。需要把 Devtool 宽度拉到很长,同时再在 React Devtools 设置的 Profiler 选项卡中,确认下取消勾选这里的 hide commits 选项。


之后我们可以看到有很多耗时很短的子组件展示了出来,由于每个字段插空都被 Popover 包裹,绑定 onClick 事件等操作,内部也包含了一些简单的判断,每个组件大概耗时不到 0.1ms。

星星之火可以燎原,虽然单个个体看耗时微不足道,但在字段较多的极端场景下,还是会给 React 虚拟 DOM 的 Diff 带来不小的负担,最终造成卡顿感。



优化方案

我们可以将每一项子组件抽离出来,封装成简单的纯函数组件,并使用 React Memo 包裹。

一个函数的返回结果只依赖于它的参数、无任何副作用、相同的输入总能得到相同的输出,该函数就可以称为一个纯函数。《请保持你的组件纯粹》

纯函数组件的优势在于我们可以通过判断函数入参是否改变,来决定是否跳过渲染。这是简单且安全的做法,因为纯函数总是返回相同的结果,可以安全地缓存它们。


梳理依赖关系,尽量减少入参的更新,保持组件的纯粹。

例如:当前激活的对象的高亮,先前是传递激活的 id 进来,在内部判断是否是自己。可以改成在外部判断完,向内传递布尔值,减少子组件的重复渲染。

指定 Memo 只去判断有可能变化的值,最小化 memo 的 diff 成本。


优化效果评估:

对相同输入项执行相同的输入操作,外层组件的单次渲染时长再次减少明显,由 25.9ms 降至 9.5ms,再次提升了约 63.3%,相较最开始的 195.5ms,已提升了 95.14%,输入时已感受不到任何卡顿和延迟。



总结
==

本文首先简述了发生卡顿的原因,并总结了日常开发中常用的React优化方式。我们所做的一切最终目标是为了帮助React更快、更高效的完成遍历渲染的过程,使整个更新链路尽可能的短的走完

  1. 控制组件重渲染的波及范围
  2. 避免组件入参的不必要变更
  3. 避免频繁、重复、无意义的 setState

随后通过上面的排查思路,我们已成功的将表单值改变时的渲染耗时控制在了 9 毫秒左右,此时用户进行常规编辑操作时,已感受不到任何卡顿,用户体验得到了很大程度的提升,通过下表我们可以快速回顾下所使用的优化方式。

优化方式 优化前耗时 优化后耗时 提升百分比
通过Memo阻断整个表单部分重渲染问题,useCallback包裹回调方法。 195ms 88.3ms 54.71%
通过自行实现简易Diff拦截,减少不必要的setState调用。 88.3ms 25.9ms 70.6%
复杂计算使用useMemo缓存结果,抽出简单的纯函数组件,使用Memo包裹 25.9ms 9.5ms 63.3%
总计提升 195ms 9.5ms 95.14%

写在最后

性能优化是一件有价值、有挑战、也需要耐心、需要持续 防劣化 的事情,在日常需求迭代中养成良好的开发习惯也是必不可少的。

同时,正如克努特原则所说——“过早的优化是万恶之源”。 作为一个业务研发团队,我们需要分清当前项目中的主次要矛盾,不宜在开发的早期快速迭代阶段就过分追求性能优化。这可能会导致代码变得过于复杂、难以维护,并且容易引入难以理解的错误。

相比于过早优化,在早期阶段更重要的是先确保代码的正确性和需求实现。 只有在这个基础上,当项目逐渐趋于稳定后,才能更好的进行有针对性的优化,以提升系统的性能。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Android 实现html css富文本解析引擎 前言 思

发表于 2023-12-20

前言

企业微信20231220-141336@2x.png

Android 中 TextView 可以实现简单的HTML解析,将 Html 文本封装为 Spannable 数据实现图文混排等富文本效果,但是问题很多。

  • 1、Android系统中提供的解析能力不够强,提供的CSS样式支持不足,对于css 属性的解析和支持很弱
  • 2、不支持多个多种css 样式同时解析
  • 3、SDK 中提供的 Html.TagHandler 无法获取到标签属性
  • 4、无法支持自定义Html 标签
  • 5、无法支持自定义CSS属性

基于以上缺陷,如果我们想在TextView中支持更丰富的样式,相对来说SpannableString也能实现,但是SpannableString有个明显的缺点,通用性不够高且不够自动化,但是作为Html,他的通用性是目前来说比较高的,同时借助Html的实现,自动话能力也很高,我们常用的markdown最终也会转为css + html样式。

其实对比浏览器中博客页面和手机app中展示的博客页面,你就会发现手机端支持很弱,根本没有支持主题,甚至还不如使用WebView的效果,所以,提高Html富文本能力也是很重要的。

本篇将通过自定义解析器的方式,实现一个Html标签和CSS可扩展、增强型的引擎。使得TextView支持更多Html和CSS解析和渲染的能力,实现TextView支持更多的富文本效果。

思路

  • 方案1: 自定义一套 HTML 解析器,其实很简单,复制一份 android.text.Html,替换其中 SDK 隐藏的 XmlReader 即可
  • 方案2:移花接木,通过 Html.TagHandler 夺取解析流程处理器,然后获得拦截解析 html标签 的能力,在拦截到标签之后自行解析。

这两种方案实质上都是可行的,第一种的话要实现自己的 SaxParser 解析,但工作量不小,因此这里我们主要提供方案二的实现方式,同时也能和原有的逻辑相互切换。

企业微信20240221-074937@2x.png

最终方案:移花接木

之所以可以移花接木,是因为 TagHandler 会被作为 Html 中标签解析的最后一个流程语句,当遇到自定义的或者 Html 类无法解析的标签,标签调用 TagHandler 的 handleTag 方法会被回调,同时可以获得 TagName,Editable,XmlReader,然后我们便可移花接木。

  • 为什么可以移花接木?
  • 答案: 在android.text.html类中,只有无法解析的标签才走TagHandler逻辑,因此我们给的起始标签必须不让他解析,下面过程中你就能体会到。

我们移花接木的核心入口是TagHandler,如果TagHandler#handleTag的第一个参数是true,表示开始解析任意标签,false为结束解析任意标签,当然,这里的开始是对所有标签都有效。

1
2
3
4
java复制代码public static interface TagHandler {
public void handleTag(boolean opening, String tag,
Editable output, XMLReader xmlReader);
}

我们紧接着封装一下解析流程

1
2
3
4
5
6
7
8
9
java复制代码@Override 
public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) {
if(opening){
startHandleTag(tag,output,xmlReader);
}else{
endHandleTag(tag,output,xmlReader);
}

}

我们前面说过,移花接木必须是html的标签无法被解析,下面是源码
android.text.HtmlToSpannedConverter#handleStartTag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码
private void handleStartTag(String tag, Attributes attributes) {
if (tag.equalsIgnoreCase("br")) {
// We don't need to handle this. TagSoup will ensure that there's a </br> for each <br>
// so we can safely emit the linebreaks when we handle the close tag.
// 对于类似br这种换行,TagSoup会自动扩充为成对表签 <br> </br>
} else if (tag.equalsIgnoreCase("p")) {
startBlockElement(mSpannableStringBuilder, attributes, getMarginParagraph());
startCssStyle(mSpannableStringBuilder, attributes);
} else if (tag.equalsIgnoreCase("ul")) {
startBlockElement(mSpannableStringBuilder, attributes, getMarginList());
}
// 省略一些代码
if (tag.equalsIgnoreCase("img")) {
startImg(mSpannableStringBuilder, attributes, mImageGetter);
} else if (mTagHandler != null) {
mTagHandler.handleTag(true, tag, mSpannableStringBuilder, mReader);
}
}

恰好html标签也无法解析,因此我这里使用html,当然你也可以随便定义,比如myhtml等,都行。

另一方面,在这里我们知道,html是树状结构,因此在树的遍历过程中什么div、section、span、body、head都会走这样的逻辑,但是平时我们使用的Html.fromHtml()的时候,一般不会加上标签在文本开始和结尾处,基于这个习惯,为了方便切换系统定义的渲染方式,我们这里加上html标签

1
java复制代码private final String H5_TAG = "html"; //自定义标签,该标签无法在原Html类中解析

当前仅当,解析到html的时候进行获取解析流程处理器,那什么是解析流程控制器呢?其实主要是4个工具
xmlReader和ContentHandler,当然同时我们也要获取,

但我们添加计数,这个原因主要是防止html出现多层嵌套的问题,导致提前归还解析器控制器

1
html复制代码<html><section> <html>第二层</html> </section></html>

核心点,下面是的夺取解析器处理器核心逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
java复制代码private void startHandleTag( String tag, Editable output, XMLReader xmlReader) {

if (tag.equalsIgnoreCase(H5_TAG)){
if(orginalContentHandler==null) {
orginalContentHandler = xmlReader.getContentHandler();
this.originalXmlReader = xmlReader; //获取XmlReader
this.originalXmlReader.setContentHandler(this);//获取控制权,让本类监听解析流程
this.originlaEditableText = output; //获取到SpannableStringBuilder

}
count++;
}

}

private void endHandleTag( String tag, Editable output, XMLReader xmlReader) {
if(tag.equalsIgnoreCase(tag)){
count--;
if(count==0 ){
this.originalXmlReader.setContentHandler(this.orginalContentHandler);
//将原始的handler交还
this.originalXmlReader = null;
this.originlaEditableText = null;
this.orginalContentHandler = null;
//还原控制权
}
}

}

接手控制器之后,我们当然是需要解析的,但是解析需要我们坚挺ContentHandler,具体实现如下
首先对标签进行管理

1
2
js复制代码//自定义解析器集合 
private final Map<String,HtmlTag> tagHandlerMap;

进行拦截解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
java复制代码@Override
public void startElement(String uri, String localName, String qName, Attributes atts) throws SAXException {

if (localName.equalsIgnoreCase(H5_TAG)){
//防止 <html>多次内嵌
handleTag(true,localName,this.originlaEditableText,this.originalXmlReader);
}else if(canHandleTag(localName)){ //拦截,判断是否可以解析该标签

final HtmlTag htmlTag = tagHandlerMap.get(localName); //读取自定义解析器开始解析
htmlTag.startHandleTag(this.originlaEditableText,atts);

}else if(orginalTags.contains(localName)){ //无法解析的优先让原Html类解析
this.orginalContentHandler.startElement(uri,localName,qName,atts);
}else{
Log.e(LOG_TAG,"无法解析的标签<"+localName+">");
}

}

private boolean canHandleTag(String tagName) {
if(!tagHandlerMap.containsKey(tagName)){
return false;
}
final HtmlTag htmlTag = tagHandlerMap.get(tagName);
return htmlTag!=null;

}

@Override
public void endElement(String uri, String localName, String qName) throws SAXException {

if (localName.equalsIgnoreCase(H5_TAG)){
//防止 <html>多次内嵌
handleTag(false,localName,this.originlaEditableText,this.originalXmlReader);
}else if(canHandleTag(localName)){
final HtmlTag htmlTag = tagHandlerMap.get(localName); //读取自定义解析器结束解析
htmlTag.endHandleTag(this.originlaEditableText);
}else if(orginalTags.contains(localName)){
this.orginalContentHandler.endElement(uri,localName,qName);
}else{
Log.e(LOG_TAG,"无法解析的标签</"+localName+">");
}
}

支持自定义标签

其实支持html样式最好还是对标签做处理,单纯的修改css还不如继承父类,好处是有些css样式是可以共用的,不过前提是。

但是在实现代码前,最好研究下Html对标签的标记和提取方法,方便我们后续扩展,下面方法参考android.text.Html类实现。

什么是标记?

在我们创建SpannbleString的时候,我们会对Text段加一些标记,当然标记是可以随便定义的,即便你把它标记成String类型或者Activity类型也是可以的,重要是在渲染逻辑中提取出标记

怎么渲染

这个得研究SpannbleString或者 android.text.Html类,主要是将标记转为TextView能渲染的各种Span,如BackgroundColorSpan和ForegroundSpan等。

1
2
3
4
java复制代码      //开始解析,主要负责css参数解析和标签标记
public abstract void startHandleTag(Editable text, Attributes attributes);
//结束解析 负责渲染
public abstract void endHandleTag(Editable text); //结束解析

下面是Html标签的基类,继承该类即可实现你自己的标签和css解析逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
java复制代码public abstract class HtmlTag {

private Context context;

public HtmlTag(Context context) {
this.context = context;
}

public Context getContext() {
return context;
}

private static final Map<String, Integer> sColorNameMap;

static {
sColorNameMap = new ArrayMap<String, Integer>();
sColorNameMap.put("black", Color.BLACK);
sColorNameMap.put("darkgray", Color.DKGRAY);
sColorNameMap.put("gray", Color.GRAY);
sColorNameMap.put("lightgray", Color.LTGRAY);
sColorNameMap.put("white", Color.WHITE);
sColorNameMap.put("red", Color.RED);
sColorNameMap.put("green", Color.GREEN);
sColorNameMap.put("blue", Color.BLUE);
sColorNameMap.put("yellow", Color.YELLOW);
sColorNameMap.put("cyan", Color.CYAN);
sColorNameMap.put("magenta", Color.MAGENTA);
sColorNameMap.put("aqua", 0xFF00FFFF);
sColorNameMap.put("fuchsia", 0xFFFF00FF);
sColorNameMap.put("darkgrey", Color.DKGRAY);
sColorNameMap.put("grey", Color.GRAY);
sColorNameMap.put("lightgrey", Color.LTGRAY);
sColorNameMap.put("lime", 0xFF00FF00);
sColorNameMap.put("maroon", 0xFF800000);
sColorNameMap.put("navy", 0xFF000080);
sColorNameMap.put("olive", 0xFF808000);
sColorNameMap.put("purple", 0xFF800080);
sColorNameMap.put("silver", 0xFFC0C0C0);
sColorNameMap.put("teal", 0xFF008080);
sColorNameMap.put("white", Color.WHITE);
sColorNameMap.put("transparent", Color.TRANSPARENT);

}

@ColorInt
public static int getHtmlColor(String colorString){

if(sColorNameMap.containsKey(colorString.toLowerCase())){
Integer colorInt = sColorNameMap.get(colorString);
if(colorInt!=null) return colorInt;
}

return parseHtmlColor(colorString.toLowerCase());
}
//颜色解析器,我们做下扩展,使得其支持argb,还有很多可以自己实现
@ColorInt
public static int parseHtmlColor( String colorString) {

if (colorString.charAt(0) == '#') {
if(colorString.length()==4){
StringBuilder sb = new StringBuilder("#");
for (int i=1;i<colorString.length();i++){
char c = colorString.charAt(i);
sb.append(c).append(c);
}
colorString = sb.toString();
}
long color = Long.parseLong(colorString.substring(1), 16);
if (colorString.length() == 7) {
// Set the alpha value
color |= 0x00000000ff000000;
} else if (colorString.length() == 9) {

int alpha = Integer.parseInt(colorString.substring(1,3),16) ;
int red = Integer.parseInt(colorString.substring(3,5),16);
int green = Integer.parseInt(colorString.substring(5,7),16);
int blue = Integer.parseInt(colorString.substring(7,8),16);
color = Color.argb(alpha,red,green,blue);
}else{
throw new IllegalArgumentException("Unknown color");
}
return (int)color;
}
else if(colorString.startsWith("rgb(") || colorString.startsWith("rgba(") && colorString.endsWith(")"))
{
colorString = colorString.substring(colorString.indexOf("("),colorString.indexOf(")"));
colorString = colorString.replaceAll(" ","");
String[] colorArray = colorString.split(",");
if(colorArray.length==3){
return Color.argb(255,Integer.parseInt(colorArray[0]),Integer.parseInt(colorArray[1]),Integer.parseInt(colorArray[2]));
}
else if (colorArray.length==4){
return Color.argb(Integer.parseInt(colorArray[3]),Integer.parseInt(colorArray[0]),Integer.parseInt(colorArray[1]),Integer.parseInt(colorArray[2]));
}

}
throw new IllegalArgumentException("Unknown color");
}

//负责提取标记
public static <T> T getLast(Spanned text, Class<T> kind) {

T[] objs = text.getSpans(0, text.length(), kind);
if (objs.length == 0) {
return null;
} else {
return objs[objs.length - 1];
}
}
//开始解析,主要负责css参数解析和标签标记
public abstract void startHandleTag(Editable text, Attributes attributes);
//结束解析 负责渲染
public abstract void endHandleTag(Editable text); //结束解析

}

下面我们以实现

标签为例,这样我们就重新定义了
标签,当然名字不重要,重要的是你可以随便写,如标签或者BaobaoSection

定义CSS标记

定义CSS标记是为了记录标签中css 的某一类样式,比如Font和字体相关,background的和背景相关。此类标记是标签解析开始的时候进行标记。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码  public static class Font{  //定义标记
int textSize;
int textDecordation;
int fontWeidght;

public Font(int textSize,int textDecordation,int fontWeidght) {
this.textSize = textSize;
this.textDecordation = textDecordation;
this.fontWeidght = fontWeidght;
}
}

public static class Background{ //定义标记
int color;
public Background(int color) {
this.color = color;
}
}

定义Android Span标记

通过上面的css属性标记,仅仅是知道有哪些CSS属性在标签中,但是如何渲染标记呢?这就得依赖TextView中的各种Span标签了。

当然Span有很多种,我们可以选择系统中的,也可以自己定义,我这里为了让FontSpan更强大,自定义了一个新的。注意,这个Span是Spannable中的StyleSpan,和Html Span标签不是同一个概念。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
java复制代码public class TextFontSpan extends AbsoluteSizeSpan {

public static final int FontWidget_NORMAL= 400;
public static final int FontWidget_BOLD = 750;

public static final int TextDecoration_NONE=0;
public static final int TextDecoration_UNDERLINE=1;
public static final int TextDecoration_LINE_THROUGH=2;
public static final int TextDecoration_OVERLINE=3;

private int fontWidget = -1;
private int textDecoration = -1;

private int mSize = -1;

public TextFontSpan(int size ,int textDecoration,int fontWidget) {
this(size,false);
this.mSize = size;
this.fontWidget = fontWidget;
this.textDecoration = textDecoration;
//这里我们以px作为单位,方便统一调用
}

/**
* 保持构造方法无法被外部调用
* @param size
* @param dip
*/
protected TextFontSpan(int size, boolean dip) {
super(size, dip);
}

public TextFontSpan(Parcel src) {
super(src);
fontWidget = src.readInt();
textDecoration = src.readInt();
mSize = src.readInt();
}

@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeInt(fontWidget);
dest.writeInt(textDecoration);
dest.writeInt(mSize);
}

@Override
public void updateDrawState(TextPaint ds) {
if(this.mSize>=0){
super.updateDrawState(ds);
}

if(fontWidget==FontWidget_BOLD) {
ds.setFakeBoldText(true);
}else if(fontWidget==FontWidget_NORMAL){
ds.setFakeBoldText(false);
}
if(textDecoration==TextDecoration_NONE) {
ds.setStrikeThruText(false);
ds.setUnderlineText(false);
}else if(textDecoration==TextDecoration_LINE_THROUGH){
ds.setStrikeThruText(true);
ds.setUnderlineText(false);
}else if(textDecoration==TextDecoration_UNDERLINE){
ds.setStrikeThruText(false);
ds.setUnderlineText(true);
}

}

@Override
public void updateMeasureState(TextPaint ds) {
if(this.mSize>=0){
super.updateMeasureState(ds);
}

if(fontWidget==FontWidget_BOLD) {
ds.setFakeBoldText(true);
}else if(fontWidget==FontWidget_NORMAL){
ds.setFakeBoldText(false);
}

if(textDecoration==TextDecoration_NONE) {
ds.setStrikeThruText(false);
ds.setUnderlineText(false);
}else if(textDecoration==TextDecoration_LINE_THROUGH){
ds.setStrikeThruText(true);
ds.setUnderlineText(false);
}else if(textDecoration==TextDecoration_UNDERLINE){
ds.setStrikeThruText(false);
ds.setUnderlineText(true);
}
}
}

完整的Html Section标签逻辑

下面实现对Html中的Section标签扩展,使其支持sp、font-size、background-color等css属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
java复制代码public class SectionTag extends HtmlTag {


public SectionTag(Context context) {
super(context);
}


private int getHtmlSize(String fontSize) {
fontSize = fontSize.toLowerCase();
if(fontSize.endsWith("px")){
return (int) Double.parseDouble(fontSize.substring(0,fontSize.indexOf("px")));
}else if(fontSize.endsWith("sp") ){
float sp = (float) Double.parseDouble(fontSize.substring(0,fontSize.indexOf("sp")));
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,sp,getContext().getResources().getDisplayMetrics());
}else if(TextUtils.isDigitsOnly(fontSize)){ //如果不带单位,默认按照sp处理
float sp = (float) Double.parseDouble(fontSize);
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,sp,getContext().getResources().getDisplayMetrics());
}
return -1;
}

private static String getTextColorPattern(String style) {
String cssName = "text-color";
String cssVal = getHtmlCssValue(style, cssName);
if(TextUtils.isEmpty(cssVal)){
cssName = "color";
cssVal = getHtmlCssValue(style, cssName);
}
return cssVal;
}

@Nullable
private static String getHtmlCssValue(String style, String cssName) {
if(TextUtils.isEmpty(style)) return null;
final String[] keyValueSet = style.toLowerCase().split(";");
if(keyValueSet==null) return null;
for (int i=0;i<keyValueSet.length;i++){
final String match = keyValueSet[i].replaceAll(" ","").toLowerCase();
if(match.indexOf(cssName)==0){
final String[] parts = match.split(":");
if(parts==null || parts.length!=2) continue;
return parts[1];
}
}
return null;
}

private static String getBackgroundColorPattern(String style) {
String cssName = "background-color";
String cssVal = getHtmlCssValue(style, cssName);

if(TextUtils.isEmpty(cssVal)){
cssName = "bakground";
cssVal = getHtmlCssValue(style, cssName);
}

return cssVal;
}

private static String getTextFontSizePattern(String style) {
String cssName = "font-size";
String cssVal = getHtmlCssValue(style, cssName);
if(TextUtils.isEmpty(cssVal)){
cssName = "text-size";
cssVal = getHtmlCssValue(style, cssName);
}
return cssVal;
}

private static String getTextDecorationPattern(String style) {
String cssName = "text-decoration";
String cssVal = getHtmlCssValue(style, cssName);
return cssVal;
}
private static String getTextFontPattern(String style) {
String cssName = "font-weight";
String cssVal = getHtmlCssValue(style, cssName);
return cssVal;
}


public static class Font{ //定义标记
int textSize;
int textDecordation;
int fontWeidght;

public Font( int textSize,int textDecordation,int fontWeidght) {
this.textSize = textSize;
this.textDecordation = textDecordation;
this.fontWeidght = fontWeidght;
}
}

public static class Background{ //定义标记
int color;
public Background(int color) {
this.color = color;
}
}

@Override
public void startHandleTag(Editable text, Attributes attributes) {
String style = attributes.getValue("", "style");
if(TextUtils.isEmpty(style)) return;


String textColorPattern = getTextColorPattern(style);
if (!TextUtils.isEmpty(textColorPattern)) {
int c = getHtmlColor(textColorPattern);
c = c | 0xFF000000;
start(text,new ForegroundColorSpan(c));

}

startMarkTextFont(text,style);

String backgroundColorPattern = getBackgroundColorPattern(style);
if (!TextUtils.isEmpty(backgroundColorPattern)) {
int c = getHtmlColor(backgroundColorPattern);
c = c | 0xFF000000;
//注意,第二个参数可以为任意Object类型,这里起到标记的作用
start(text,new Background(c));
}

}

private void startMarkTextFont(Editable text ,String style) {

String fontSize = getTextFontSizePattern(style);
String textDecoration = getTextDecorationPattern(style);
String fontWidget = getTextFontPattern(style);

int textSize = -1;
if(TextUtils.isEmpty(fontSize)){
if(!TextUtils.isEmpty(fontSize)){
textSize = getHtmlSize(fontSize);
}
}
int textDecorationVal = -1;
if(!TextUtils.isEmpty(textDecoration)){
if(textDecoration.equals("underline")) {
textDecorationVal = TextFontSpan.TextDecoration_UNDERLINE;
}else if(textDecoration.equals("line-through")){
textDecorationVal = TextFontSpan.TextDecoration_LINE_THROUGH;
}
else if(textDecoration.equals("overline")){
textDecorationVal = TextFontSpan.TextDecoration_OVERLINE;//暂不支持
} else if(textDecoration.equals("none")){
textDecorationVal = TextFontSpan.TextDecoration_NONE;
}
}
int fontWeidgtVal = -1;
if(!TextUtils.isEmpty(fontWidget)){
if(textDecoration.equals("normal")) {
fontWeidgtVal = TextFontSpan.FontWidget_NORMAL;
}else if(textDecoration.equals("bold")){
fontWeidgtVal = TextFontSpan.FontWidget_BOLD;
}
}

start(text,new Font(textSize,textDecorationVal,fontWeidgtVal));
}

@Override
public void endHandleTag(Editable text){


Background b = getLast(text, Background.class); //读取出最后标记类型
if(b!=null){
end(text,Background.class,new BackgroundColorSpan(b.color)); //设置为Android可以解析的24种ParcelableSpan基本分类,当然也可以自己定义,但需要集成原有的分类
}

final ForegroundColorSpan fc = getLast(text, ForegroundColorSpan.class);
if(fc!=null){
end(text,ForegroundColorSpan.class,new ForegroundColorSpan(fc.getForegroundColor()));
}

Font f = getLast(text, Font.class);
if (f != null) {
end(text,Font.class,new TextFontSpan(f.textSize,f.textDecordation,f.fontWeidght)); //使用自定义的
}
}

private static void start(Editable text, Object mark) {
int len = text.length();
text.setSpan(mark, len, len, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); //添加标记在最后一位,注意开始位置和结束位置
}

@SuppressWarnings("unchecked")
private static void end(Editable text, Class kind, Object repl) {
Object obj = getLast(text, kind); //读取kind类型
if (obj != null) {
setSpanFromMark(text, obj, repl);
}
}


private static void setSpanFromMark(Spannable text, Object mark, Object... spans) {
int where = text.getSpanStart(mark);
text.removeSpan(mark);
//移除原有标记,因为原有标记不是默认的24种ParcelableSpan子类,因此无法渲染文本
int len = text.length();
if (where != len) {
for (Object span : spans) {
text.setSpan(span, where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); //注意:开始位置和结束位置,因为SpannableStringBuilder的append添加字符方法导致len已经大于where了
}
}
}
}

用法

替换拦截标签,下面我们替换默认的section标签逻辑,当然你也可以注册成其他的标签,如 field、custom等

1
2
3
4
5
java复制代码
HtmlTagHandler htmlTagHandler = new HtmlTagHandler();
htmlTagHandler.registerTag("section",new SectionTag(targetFragment.getContext()));
htmlTagHandler.registerTag("custom",new SectionTag(targetFragment.getContext()));
htmlTagHandler.registerTag("span",new SectionTag(targetFragment.getContext()));

然后写一段html,输入进去即可

1
2
3
4
5
6
java复制代码
String source = "<html>今天<section style='color:#FFE31335;font-size:16sp;background-color:white;'>星期三</section>,<section style='color:#fff;font-size:14sp;background-color:red;'>但是我还要加班</section><html>";

final Spanned spanned = Html.fromHtml(source, htmlTagHandler, htmlTagHandler);

textView.setText(spanned );

或者

1
2
3
4
5
java复制代码
String source = "<html>今天<span style='color:#FFE31335;font-size:16sp;background-color:white;'>星期三</span>,<custom style='color:#fff;font-size:14sp;background-color:red;'>但是我还要加班</custom><html>";

final Spanned spanned = Html.fromHtml(source, htmlTagHandler, htmlTagHandler);
textView.setText(spanned );

注意: 标签必须加到要解析的文本段,否则 Android 系统仍然会走 Html 的解析流程,因为我们前面使用的这个来拦截解析器控制器的。

上面的返回结构是Spanned,实际上是配置了各种Span的描述文本,如TextSpan、ForegroundSpan,这意味着,如果我们要扩充css样式,除了标签自定义之外,Span也可能需要自定义。

1
java复制代码final Spanned spanned = Html.fromHtml(source, htmlTagHandler, htmlTagHandler);

总结

自定义Html标签,使得TextView具备更多更强的html解析能力,其次也能自定义标签,并且实现更多css属性样式,整个过程看似复杂,实际上了解了xml或者html解析过程,你就会对控制流更加熟悉。 另一个知识点是Android Span标记,我们可以注意到,整个过程打了2次标记,第一次是普通css标记,负责记录css属性值,第二次打上Android Span标记,用于TextView渲染逻辑。

当然,我们篇头说过,自行拷贝一份android.text.Html的代码也是阔以的,有些类需要自己找,因为framework的类有些我们无法引用到。

源码

本篇已开源,从下面开源地址获取接口。
AndroidHtmlTag

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官:MySQL是如何保证数据不丢失的? 前言 Buffe

发表于 2023-12-20

前言

上篇文章《InnoDB在SQL查询中的关键功能和优化策略》对InnoDB的查询操作和优化事项进行了说明。但是,MySQL作为一个存储数据的产品,怎么确保数据的持久性和不丢失才是最重要的,感兴趣的可以跟随本文一探究竟。

Buffer Pool 和 DML 的关系

InnoDB中的「Buffer Pool」除了在查询时起到提高效率作用,同样,在insert、update、delete这些DML操作时为了减少和磁盘的频繁交互,也会将这些更新先在Buffer Pool中缓存的数据页进行操作,随后将这些有更新的「脏页」刷到磁盘中。

这个时候就涉及到一个问题:如果MySQL服务宕机了,这些在内存中更新的数据会不会丢失?

答案是一定会存在丢失现象的,只不过MySQL做到了尽量不让数据丢失。接下来来看一下MySQL是怎么做的。

这里还是把结构图贴一下,方便下面介绍时看图理解。

在这里插入图片描述

DML操作流程

加载数据页

通过上文可以知道,行记录是在数据页中,所以,当InnoDB接收到DML操作请求后,还是会去找「数据页」,查找的过程跟上文查询行记录流程是一样。这里说一下,insert的请求会根据主键索引去找数据页,update、delete根据查询条件去找数据页,总之「数据页」要加载到「Buffer Pool」之后才会进行下一步操作。

更新记录

定位到数据页后,insert操作就是往数据页中添加一行记录,delete是标记一下行记录的‘删除标记’,而update则是先删除再添加,这是因为存在可变长的字段类型,比如varchar,每次更新时,这种类型的数据占用内存是不固定的,所以先删除再添加。

这里的删除标记是行记录的字段,也就是除了业务字段数据,InnoDB默认为每行记录添加的字段,所以一个行记录大概如下图,这也是之前提到过的「行格式」。

在这里插入图片描述

找到数据页并且更新记录之后DML操作就算完成了,但是还没有落地到磁盘。

这个时候直接刷新到磁盘视为完成不可以吗?

数据持久化方案

可以是可以,但是如果每次的DML操作都要将一个16KB的数据页刷到磁盘,其效率是极低的,估计也就没有人用MySQL了。但是如果不刷新到磁盘,就会发生MySQL服务宕机数据会丢失现象。MySQL在这里的处理方案是:

  1. 等待合适的时机将批量的「脏页」异步刷新到磁盘。
  2. 先快速将更新的记录以日志的形式刷新到磁盘。

先看第一点,什么时候是合适的时机?

合适的时机刷盘

当「脏页」在「Buffer Pool」中达到某个阈值的时候,InnoDB会将这些脏页刷新到磁盘中。这个阈值可以通过 innodb_max_dirty_pages_pct 这个参数查看或设置,相关命令如下:

1
2
3
4
sql复制代码-- 查看脏页刷新阈值
show variables like 'innodb_max_dirty_pages_pct'
-- 在线设置脏页刷新阈值,当脏页在Buffer Pool占用70%的时候刷新
SET GLOBAL innodb_max_dirty_pages_pct = 70

在这里插入图片描述

当然,这个合适的时机只是为了减少与磁盘的交互,用来提高性能的,并不能确保数据不丢失。

双写机制

在刷新「脏页」这里还有一个非常重要的注意事项就是:因为InnoDB的页大小为16KB,而一般操作系统的页大小为4KB。意味着InnoDB将这些「脏页」向磁盘刷新时,在操作系统层面会被分成4个4KB的页,这样的话,如果其中有一页因为MySQL宕机或者其他异常导致没有成功刷新到磁盘,就会出现「页损坏现象」,数据也就不完整了。

在这里插入图片描述

所以InnoDB在这里采用的双写机制,在将这些「脏页」刷新到磁盘之前先会往结构图中的「Doublewrite Buffer」中写入,随后再刷新到对应的表空间中,当出现故障时就可以通过双写缓冲区进行恢复。

向「Doublewrite Buffer」就不会发生「页损坏现象」?

「Doublewrite Buffer」的大小是独立且固定的,不是基于页的大小来划分的。所以不受操作系统中的页大小限制,也不会发生「页损坏现象」。并且先以顺序IO的方式向「Doublewrite Buffer」写入数据页,再以随机IO异步刷新到表空间这种方式还可以提高写入性能。

shuiyiimg_1.png

再看第二点,为什么以日志的形式先刷新到磁盘?

日志先行机制

在「Buffer Pool」中更新完数据页后,由于不会及时将这些「脏页」刷新到磁盘,为了避免数据丢失,会将本次的DML操作向「Log Buffer」中写一份并且刷新到磁盘中,相比16KB的数据页来说,这个数据量会小很多,而且写入日志文件时是追加操作,属于顺序IO,效率较高。如下图,哪种方式写入效率更高是显而易见的。

shuiyiimg.png

这里说的日志文件就是经常会听到的「Redo Log」,即使MySQL宕机了,通过磁盘的redolog,也可以在MySQL启动时尽可能的将数据恢复到宕机之前样子。当然,还有「Undo Log」,因为对本文重点没有直接影响,所以不对此展开说明。

这种日志先行(WAL)的机制也是MySQL用于提高效率和保障数据可靠的一种方式。

为什么是尽可能的恢复?

日志刷盘机制

因为「Log Buffer」中的日志数据什么时候向磁盘刷新则是由 innodb_flush_log_at_trx_commit 和 innodb_flush_log_at_timeout 这两个参数决定的。

  • innodb_flush_log_at_trx_commit默认为1,也就是每次事务提交后就会刷新到磁盘。
  • 当innodb_flush_log_at_trx_commit设置为0时,则不会根据事务提交来刷新,而是根据innodb_flush_log_at_timeout设置的时间定时刷新,这个时间默认为1秒。
  • 当innodb_flush_log_at_trx_commit设置为2时,仅将日志写入操作系统中的缓存中,随后跟随根据innodb_flush_log_at_timeout定时刷新。

注意:如果在innodb_flush_log_at_timeout内没有发生事务提交,也会刷新到磁盘。

如果在MySQL服务宕机的时候,「Log Buffer」中的日志没有刷新到磁盘,这部分数据也是会丢失的,在重启后也不会恢复。所以如果不想丢失数据,在性能还可以的情况下,尽量将innodb_flush_log_at_trx_commit设置为1。

「redo log」是怎么恢复数据的?

Redo Log 恢复数据

首先,redo log会记录DML的操作类型、数据的表空间、数据页以及具体修改的内容,以 insert into t1(1,'hi')为例,对应的redo log内容大概这样的

shuiyiimg_2.png

假如 innodb_flush_log_at_trx_commit 的值为1,那么当该DML操作事务提交后,就会将 redo log 刷新到磁盘。成功刷新到磁盘后,就可以视为数据被写入成功。

此时如果「脏页」还没刷新到磁盘便宕机,那么在下次MySQL启动时便去加载redo log,如果redo log存在数据则意味着需要恢复数据。这个时候就可以通过redo log中的内容重新构建「脏页」,从而恢复到宕机之前的状态。

怎么构建「脏页」呢?

其实在每次的redo log写入时都会记录一个「LSN(log sequence number)」,同时这个值在「数据页」中记录最后一次被修改的日志序列位置。MySQL在启动时通过LSN来对比 redo log 和数据页,如果数据页中的LSN小于 redo log 的LSN,则会将该数据页加载到「Buffer Pool」,然后根据 redo log 的内容构建出「脏页」,等待下次刷新到磁盘,数据也就恢复了。如下图

shuiyiimg_9.png

注意:这个恢复的过程重点在redo上,实际上还涉及到「Change Buffer」、「Undo Log」等操作,这里没有展开说明。

「Doublewrite Buffer」和「redo log」都是恢复数据的,不冲突吗?

不冲突,「Doublewrite Buffer」是对「页损坏现象」的整个数据页进行恢复,Redo Log只能对某次的DML操作进行恢复。

总结

InnoDB通过以上的操作可以尽可能的保证MySQL不丢失数据,最后再总结一下MySQL是如何保障数据不丢失的:

  1. 为了避免频繁与磁盘交互,每次DML操作先在「Buffer Pool」中的缓存页中执行,缓存页有更新之后便成为「脏页」,随后根据innodb_max_dirty_pages_pct这个参数将「脏页」刷新到磁盘。
  2. 因为「脏页」在刷新到磁盘之前可能会存在MySQL宕机等异常行为导致数据丢失,所以MySQL采用日志先行(WAL)机制,将DML操作以日志的形式进行记录到「Redo Log」中,随后根据innodb_flush_log_at_trx_commit 和 innodb_flush_log_at_timeout这两个参数将「Redo Log」刷新到磁盘,以便恢复。
  3. 在向磁盘刷新「脏页」时,为了避免发生「页损坏」现象,InnoDB采用双写机制,先将这些脏页顺序写入「Doublewrite Buffer」中,随后再将数据页异步刷新到各个表空间中,这种方式既能提高写入效率,又可以保障数据的完整性。
  4. 如果在「脏页」刷新到磁盘之前,MySQL宕机了,那么会在下次启动时通过 redo log 将脏页构建出来,做到数据恢复。
  5. 通过以上步骤,MySQL做到了尽可能的不丢失数据。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

高并发场景下,库存系统的架构挑战,一共9个关键性问题

发表于 2023-12-19

五阳长期从事虚拟电商领域,在今天的分享中主要聊聊虚拟订单的库存架构设计。

一共9个关键问题

  1. 秒杀等高并发场景的库存系统难点

在高并发和低并发场景下,我们需要考虑不同的实现方法。

对于高并发场景,要求一秒钟能够支持一万次库存扣减操作。这种情况下,我们可以选择Redis作为库存系统的存储模型。Redis具有高性能和高并发的特点,能够满足这种高并发的秒杀场景。

但是相比MySQL 版库存方案,基于Redis版本的库存系统要更加的笨重和复杂,数据可靠性更低、库存功能的丰富度更低,可维护性也要更差。如果在低并发业务场景使用 Redis实现库存能力,想要实现同样的功能除牺牲数据可靠性,也牺牲了数据一致性,不光如此,Redis版库存也难以实现多商品库存扣减、分时库存扣减等场景。

接下来我们优先探讨使用MySQL实现秒杀库存的瓶颈点

秒杀场景问题

在秒杀场景中,当大量用户同时抢购同一件商品时,需要同时更新商品库存。在使用InnoDB数据库时,通过行锁和死锁检测机制来确保数据并发的一致性。然而,由于大量的竞争和并发操作,行锁和死锁检测机制会导致数据库的CPU资源被短时间内占满,使得整个数据库几乎无法响应其他请求。

如何简单优化秒杀问题

秒杀的性能瓶颈并非完全因为,”大量请求集中更新一条库存很慢”,而是因为MySQL 开启死锁检测。当大量扣减库存请求到MySQL,MySQL会根据深度优先遍历整个图是否有环,有环说明死锁,这个环是事务和锁构建的环。由于请求量巨大,这个图是巨大的,这将导致遍历过程CPU负载极高。那么如果MySQL 关闭死锁检测,这个问题是否会不复存在呢?

MySQL 在其官方文档中,提到了高并发场景,建议可关闭死锁检测

在高并发系统上,当大量线程等待同一锁时,死锁检测可能会导致速度减慢。有时,禁用死锁检测,在发生死锁时依赖事务回滚的设置可能会更有效。可以使用该变量禁用死锁检测 innodb_deadlock_detect 。

默认情况下,innodb_deadlock_detect 死锁检测是开启的。需要注意禁用死锁检测后,当真出现死锁时,只能依赖事务超时机制结束死锁。超时时间通过 innodb_lock_wait_timeout 参数配置,默认50秒,比较长,建议调整到10s以下。

下图是 并发场景修改同一条库存记录时,开启和关闭死锁检测的性能对比。

image.png

从图中可得知,当512个客户端线程执行扣减SQL时,响应时间超过了1秒以上,系统接近于不可用。而同样并发度,关闭死锁检测,响应时间在129毫秒,系统还可用。由此可见,关闭死锁检测确实能提高秒杀场景的库存扣减性能。但是这并不是最优的思路,因为关闭死锁检测,也无法解决行锁争抢问题带来的性能下降。

AliSQL 秒杀场景MySQL

除关闭死锁检测外,AliSQL 也提供了排队的思路解决 MySQL 热点记录更新问题。

AliSQL是基于MySQL官方版本的一个分支,由阿里云数据库团队维护。宣称“在通用基准测试场景下,AliSQL版本比MySQL官方版本有着 70% 的性能提升。在秒杀场景下,性能提升 100倍”。

AliSQL解决热点记录更新问题的方法是通过排队,以解决死锁检测和行锁争抢的问题。需要你在SQL中明确指定更新记录的ID,以让AliSQL知道在哪个记录上排队,同时AliSQL在这个场景禁用了死锁检测。并且由于采用了排队执行的方式,也避免了行锁的争抢问题。

我没有找到AliSQL和MySQL的性能对比报告。但我了解到,美团MTSQL的性能报告。MTSQL也使用排队思路解决热点记录更新问题。MTSQL的性能评测结果显示,基于MySQL,单条记录每秒2500次库存扣减时,系统接近不可用,而使用MTSQL,单条记录每秒超过10000次库存扣减,响应时间仍然很快。

因此,使用MySQL端的排队解决方案能够显著提升高并发场景下库存扣减的性能,相较于原生MySQL至少提升了5倍以上的性能。

使用AliSQL、MTSQL的好处

相比基于Redis,使用MySQL 服务端排队技术,对于业务方更加简单。Redis需要复杂的机制保证Redis和数据库的数据一致。Redis难以保证 库存扣减和库存流水的一致性,难以保证多个商品库存扣减的一致性。

使用AliSQL,可以继续使用数据库的事务机制,没有数据一致性的困扰,技术方案更加简洁和可靠。

有强大的底层存储系统,可以极大降低业务系统设计的复杂度!提高系统的健壮性!

底层越强大,上层越轻松!

  1. 多商品库存扣减如何保证一致性?

如果一笔订单购买多个商品,如何保证多商品扣减一致性呢?在低并发场景,完全可使用MySQL事务保证库存操作的一致性。

update inventory set cnt = cnt + #{buyCnt} WHERE productId = #{productId1} AND cnt + #{buyCnt} <= totalCnt

update inventory set cnt = cnt + #{buyCnt} WHERE productId = #{productId2} AND cnt + #{buyCnt} <= totalCnt

例如同时操作productId1,productId2 两个商品的库存,可以使用以上两个SQL,在同一个事务中执行。 任何一个SQL更新失败,则抛出异常,回滚事务。

高并发场景如何实现一致性呢?分别聊聊使用Redis、AliMq如何保证一致性?

如果Redis 使用Redis Cluster?

Redis中同时修改多个库存,可以使用Lua脚本,一次性检查多个库存是否充足,然后扣减库存。因为Redis是单线程模型,所以Lua脚本执行中,不会被打断,不会存在并发问题,所以每个Lua脚本可近似看成 同时成功、同时失败。

但是当Redis使用 集群模式时,无法使用修改多个Key的Lua脚本。

因为Redis-Cluster结构无法保证Lua脚本中多个Key的操作路由到一个节点,自然无法保证多Key操作的一致性。

所以使用Lua脚本实现多商品库存修改,必须确保Redis不得使用Cluster集群模式。

如果使用 AliSQL

使用 AliSQL 时,将多个商品的库存操作放到一个事务中,可保证同时成功和失败。方案更加简单,所以推荐高并发场景使用AliSQl实现库存方案,不推荐使用Redis。

  1. 如何保证库存扣减的幂等

以上库存操作的SQL,如果重复执行会导致库存重复扣减。可以考虑在库存操作事务中,新增库存扣减流水,使用订单ID作为流水幂等键,当流水新增冲突时,则说明库存重复扣减,回滚事务即可。

SQL代码示例如下

update inventory set cnt = cnt + #{buyCnt} WHERE productId = #{productId1} AND cnt + #{buyCnt} <= totalCnt

insert into inventory_op_records(……) values(……)

先扣减库存,还是先新增流水

可以考虑先增加库存流水,后修改库存。

在使用AliSQL 优化秒杀场景的库存修改时,可以设置 库存修改SQL在MySQL服务端执行完成后,立即提交事务,无需等待客户端提交事务,减少网络交互,提高性能。

ClientAliSQL开启事务 (Spring托管事务,Spring帮忙开启事务)发起新增库存流水 SQL发起库存扣减 SQL提交事务ClientAliSQL
AliSQL 支持在第三步,扣减库存时,自动提交事务,省却了一次网络开销。

同时,先扣减库存再增加流水,会导致行锁持有的时间更长,降低了库存扣减并发度。新增库存流水在前,新增流水时,还未锁定 库存行锁,其他事务可扣减库存,这样并发度更高。

(MySQL 新增记录默认是并发的 #
真丢人,工作六七年了,没搞明白MySQL插入是并发还是串行?)

回补库存也需要流水吗?

回补库存也需要流水。避免回补库存操作,重复执行,出现库存不准确现象。

库存流水表的唯一键 应该包括 orderId + productId + opType。加上库存操作方向,库存扣减和回补各自对应一条流水记录。(opType 0 为扣减,1 为回滚)

多个商品扣减库存,需要多条流水吗?

如果多个商品的库存扣减是在同一个事务中进行的,可以考虑只记录一条库存流水。

那么,对于库存回补操作,是否也应该对应一条流水呢?库存回补操作需要对应多条流水。这是因为订单可能会进行部分退款,也就是说,部分商品会退款,而部分商品不会退款。在这种情况下,就会出现部分商品的库存回滚,而另一部分商品则不会回滚。因此,一个订单的库存回补操作可能会执行多次,相应地,库存流水也应该有多条。

刚才提到的库存流水表的唯一键orderId + productId + opType。当进行库存扣减时,由于多个商品共享同一条库存流水,可以指定 productId=0 即可。

需要说明的是库存流水表可以使用 userId 进行分表,库存表可以使用 productId 进行分表,以提高并发能力。由于在一个数据库中,可以保证在一个事务中。

  1. 是否需要预占库存?

在我所看到的大部分库存系统设计中,都提到了预占库存。仿佛必须要有预占库存,五哥在这甩一句话:虚拟商品库存无需预占库存。

预占库存是实物商品库存领域的设计,用户下单完成库存预占,仓储系统发货后释放预占库存,预占库存可以监控已下单未发货库存量。由于实物商品下单完成到发货完成有一段较长的时间窗口,并且为了更好的监控未发货库存数量,设计出预占库存这样一个概念。虚拟商品领域库存不存在发货这个动作,直接扣减库存就完事,整预占库存这个概念,徒增系统理解难度,脱ku zi放 P 的设计。

假如有预占库存

如果引入预占库存,库存充足的计算公式更加复杂,库存交互场景也更加复杂。

库存是否充足的公式则变为:总库存 > 已售卖数量+预占数量+当前购买数量。这个方案跟更加复杂。

下单阶段需要增加预占库存,支付阶段需要扣减预付库存,订单取消还需要回补库存,订单超时未支付也需要扣减预占库存。需要4个场景交互。

且每一个环节工作内容不同:分别包括增加预占库存、扣减预占库存、扣减预占库存+扣减实际库存、回补库存。这4步,每一步的内容都不同,非常复杂。

每一个写操作都需要保证幂等性,这4个接口,就需要设计4种幂等方案。例如如何保证“ 扣减预占库存” 的幂等,如何保证 “扣减预占库存+扣减实际库存的幂等”

预占库存的数据准确性、预占库存和总库存的一致性也很难保证。

引入预占库存概念,大大增加了系统的复杂度。
image.png

假如没有预占库存

如果没有预占库存,库存充足计算公式更简单,库存交互也更加简单

下单阶段扣减库存,订单取消回补库存,订单超时未支付回补库存。只需要3个场景交互,相比预占库存方案,支付完成后,减少了 “扣减实际库存+扣减预占库存” 这一步。

更重要的是没有了预占库存这个概念,库存操作只有扣减库存和回补库存这两个动作,系统设计会更加简洁。保证这两个写接口的幂等,只需要库存扣减流水和库存回补流水就能做到。

image.png

由此可见,虚拟商品库存设计时增加预占库存,属于画蛇添足的设计,得不偿失。

  1. 如何设计库存接口的语义

通过问题 4 的讨论,可以得出一个结论:不需要设置预占库存。没有预占库存后,库存接口只有两个,扣减库存和回滚库存。这两个接口如何设计接口语义呢?换句话说,上游调用这两个接口何时为成功,何时为失败呢?

库存接口的各种场景

image.png

接下来,讨论这两个接口的返回值场景

库存扣减返回值处理

库存扣减的返回场景和对应处理如下

库存扣减的返回场景 上游处理办法
库存扣减成功 上游认定:扣减成功
库存不足,扣减失败 上游认定:扣减失败
库存已扣减,无需重复扣减 上游认定:扣减成功
上游调用超时 上游认定:扣减失败
其他异常 上游认定扣减失败

扣减成功和库存不足失败的场景,上游分别认定为成功和失败处理即可,无需赘言。

值得说明的是,如果上游调用扣减库存超时,应如何处理?重新扣减库存还是直接认定失败。我认为两者都可以,但是要区分场景。

对时间不敏感的场景

当调用扣减库存超时,如果是时间不敏感的场景,例如异步发券等,可以考虑通过重试接口,获取准确的结果。一般情况下,库存都是充足的,接口超时的时候,大多数重试扣减都是可以扣减成功的。

对时间敏感的场景

当调用扣减库存超时,如果库存接口上游对时间比较敏感,调用库存扣减超时,则认定为失败。

例如提单扣减库存接口对耗时极为敏感。因为提单接口调用链路非常复杂,往往需要很多次下游接口调用和数据库调用,所以提单接口的耗时较长。同时提单时间太长,对用户影响非常大。当提单阶段扣减库存超时了,就应该认定为库存扣减失败,终止提单即可。因为库存接口超时,说明提单接口耗时已经很长了,再次重试,则会雪上加霜,不如选择抛出异常,由用户发起重试提单。

调用库存扣减超时,认定为失败,还需要调用库存回滚接口,尝试回滚库存。因为接口超时时,无法确定库存是否扣减成功。 上游应该发消息,尝试异步回滚库存。

库存回滚接口返回值

除了扣减库存超时,需要异步回滚库存。其他场景,包括订单退款,也需要扣减库存。

库存接口的语义如下

库存回滚的返回场景 上游处理
回滚成功 认定为成功
已回滚,无需重试回滚 重试请求,认定成功
已回滚,无需重试回滚 重试请求,认定成功
回滚失败 重试回滚接口
上游收到超时 重试回滚接口

回滚库存接口应该保证,如果扣减成功,则立即回滚库存;如果扣减失败或未扣减,则回滚失败。

异步回滚库存时,如果调用接口超时,上游应该重试回滚;如果返回库存已回滚,则认定为回滚成功。

总之,异步回滚库存,应该通过重试,保证回滚接口返回 成功或重复回滚 两个返回值中的一个。

  1. 记录库存余额还是记录售卖数量?

方案1:记录库存余额的方案是,每次购买时库存余额都减去购买数量。一旦库存余额小于等于 0,则无法再减少库存数量,商品则不可再售卖,直至库存得到补充。示例SQL如下

update inventory set cnt = cnt - #{buyCnt} WHERE productId = #{productId} AND cnt - #{buyCnt} >= 0


方案2:记录售卖数量的方案是,每次购买时,已售卖数量 + 当前购买数量。加和以后,一旦已购买数量大于库存总数时,则库存不足,商品则不可再对外售卖,直至库存得到补充。

update inventory set cnt = cnt + #{buyCnt} WHERE productId = #{productId} AND cnt + #{buyCnt} <= totalCnt


这两个方案都可以实现库存功能。但是在库存不足后,需要补充库存的场景,两个方案存在差异,存在优劣。

例如当前总库存为100,已售卖了51个,剩余库存为49个。按照方案1(库存余额)的要求,当库存从100增加到200时,除了库存总数需要增加,剩余库存数量也需要增加100个,变为149个。

然而,减少库存总数这一场景,方案1的实现方案更加复杂。当库存从100减少到50时,由于库存余额为49个,库存余额减掉50后,则为-1。因为库存余额为负数属于异常场景,所以需要将剩余库存设置为0。然而,这会引起数据的一致性问题。

因为库存余额不准确,所以 已售卖数量 = 库存总数 - 库存余额,这个等式也不再正确 ,如果商品需要展示 已售数量,使用这个公式就无法保证已售数量的准确性。

在方案1中,每次调整库存总数都需要调整库存余额,增加了操作复杂度。因为C端交易流程需要操作库存余额,B端调整库存总数也会涉及调整库存余额,BC端的库存操作需要互斥,否则会出现数据不一致的问题。

相比之下,方案2(记录售卖数量)则没有方案1的困境。增加或减少库存总数只需要调整相应的库存总数即可。当前售卖数量只有C端交易流程会修改,库存总数只有B端库存管理场景会修改。查询商品的已售卖数量只需要查库存的已售数量即可,而商品的库存余额可以通过库存总数减去已售数量得到。商品库存余额 = 库存总数 - 已售数量 ,BC端的操作不会影响这个等式的正确性。

综上所述,方案1使用库存余额更加复杂,并且没有明显的收益。而方案2记录售卖数量的方案更加简单,调整库存更加简洁优雅,而且不会出现数据一致性问题。通过记录售卖数量,很容易就可以知道当前商品的已售卖数量。

  1. 下单扣减库存还是支付扣减库存?

下单扣库存

下单前扣减库存,当订单取消和退款时回补库存。这个方案可以保证用户下单成功就一定能购买成功,下单阶段就占用库存的坏处是,如果大量用户虚假下单,但是不支付订单,就会有大量库存被占用,影响正常用户下单。

支付前扣库存

为了避免下单扣库存带来的问题,可以引入了一种新的方案:支付前扣减库存,当订单退款时,回补库存。这样,用户下单时不再占用库存,避免了大量库存被占用但未支付的情况。

然而,这种方案也存在一些问题。如果用户下单成功后在支付时库存不足,系统会向其提示 “库存已售罄”,这对用户体验造成了极大的影响。

我曾经在京东抢茅台的过程中遇到过这个问题,我认为只要下单成功,就应该能购买到商品,但在支付时被告知库存不足,我感觉自己被耍了,我非常气愤,从此以后我就再也不参与这个平台的秒杀抢购了。(后来也不用京东了,基本只用拼多多了)。

最好使用下单扣减库存

以上两个方案各有优劣,因为方案2(支付扣库存)的用户体验太差。所以尽量避免使用方案2(支付扣库存),优先使用方案1(下单扣库存)。

对于方案1(下单扣库存)的弊端,也有解决办法。

通常情况下,业界会限制订单支付时长,要求用户在15-30分钟内完成支付,以避免订单长时间处于待支付状态。如果订单未在规定时间内支付,订单会被取消,库存也会被还原,因此不会一直占用库存的情况发生。

大量正常用户下单后不支付而导致订单取消率过高,说明系统存在问题阻碍用户支付。这种情况下,大量占用库存属于异常场景,可以忽略不计。

只有黑产用户才会故意使用大量账号占用库存,以影响售卖。然而仔细思考一下,占用库存、影响售卖对黑产并没有好处。所以实际发生的概率非常低。只需要让订单提单接口接入风控,风控接口识别并封杀黑产用户,让风控团队和黑产斗智斗勇就能解决方案1(下单扣库存)的弊端。

在系统设计时,我们必须进行正确的权衡。方案1的缺陷是:容易受到黑产用户的攻击,但黑产动力有限,概率较低。方案2的缺陷是:用户体验极差,可能导致用户流失。

两害相权取其轻,我认为用户体验更为重要,应该选择方案1(下单扣库存)。这也是和大多数公司的选择相同。

  1. 日库存、周库存等分时库存如何实现?

以日库存为例,每日库存均生成一条库存记录,扣减库存时,需指定订单的提单时间,扣减库存模块,需根据提单时间,生成对应的 日库存 key。 扣减对应的库存。

存储模型如下

Inventory+long productId,+String inventoryKey,+int inventoryType
例如日库存会生成如下

1
2
3
ini复制代码productId = 12345;
inventoryKey = "20231106"
inventoryType = 2

其中inventoryType = 2 代表是日库存,使用日期作为InventoryKey名称

周库存的key,使用今年的第N周来表示。

1
2
3
ini复制代码productId = 12345;
inventoryKey = "202331"
inventoryType = 3

当扣减库存时,从商品的库存配置中获取到,该商品有哪类库存。如果包含日库存、周库存,构建库存扣减上下文,计算要扣减库存的Key。

在未来可以使用inventoryType进行扩展其他库存。例如月库存、年库存等也可以这样扩展。

值得一提的是,如果库存不限制时间,而是总库存,可设定 inventoryType = 1,inventoryKey = “Total” 表示总库存。

  1. 除商品库存外,还有其他库存吗?

在电商环境中,并非只有商品具备库存,很多资源实体都有库存。

例如用户领券时,当库存不足时,则无法领券,需要设置发券的库存。

例如售卖商品时,不同的渠道共用一个库存,此时库存的维度并非商品,而是渠道。

例如某个营销活动需要控制预算,需要配置活动库存,此时的库存维度并非商品,而是活动。

所以在原有的库存模型上,需要增加维度。

Inventory+int targetType,+long targetId,+String inventoryKey,+int inventoryType

  1. targetType 表示 库存资源实体的类型
  2. targetId 表示 库存资源实体的 ID。

在查询和扣减库存时,我们需要额外指定所需库存资源的类型。如果客户端是商品库存场景,就需要指定资源类型为商品;如果客户端是活动库存场景,就需要指定资源类型为活动。通过新增资源类型,我们可以实现多种业务场景共用一个库存系统。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

中后台业务开发(一)「表单原理」

发表于 2023-12-18

  1. 前言

读完本文你会收获到:

  1. Form 原理及最佳实践,提高日常开发效率。
  2. Form 源码中运用了哪些设计模式,丰富自己编码的武器库。
  1. 原理

一句话概括:数据层面,单个实例统一管理所有表单数据,通过事件订阅和通知机制进行更新与响应,更新时触发相应组件重新渲染。

2.1. Form与Item

2.1.1. 一个实例

每一个 Form 组件都有相应的实例对象,集中管理表单状态、校验规则等。大家最常看到的应该就是如下函数:

const [form] = Form.useForm()

这个函数的最基础的作用是 创建表单实例,实例中包含状态仓库以及对状态的增删查改的方法,以及事件的订阅与通知。

2.1.2. 订阅与通知

看两段简化的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
js复制代码private fieldEntities = [];

// 将Item实例存在Form实例中
private registerField = (entity) => {
// 存储子项实例
this.fieldEntities.push(entity);
...
}

// 通知Item
private notifyObservers = (
...
) => {
...
this.getFieldEntities().forEach(({ onStoreChange }) => {
onStoreChange(...);
});
...
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
js复制代码class Field extends ... {

public componentDidMount() {
...
// Item将实例注册到form实例中
this.cancelRegisterFunc = registerField(this);
...
}

// 提供给form实例发送通知的回调函数
public onStoreChange = (prevStore, namePathList, info) => {
...
switch (info.type) {
case 'reset':
...
case 'remove':
...
case 'setField':
this.reRender();
...
case 'dependenciesUpdate':
...
default:
...
break;
}
...
};

// 更新class类型的Item组件
public reRender() {
...
this.forceUpdate();
}
}

从上面的两段简化后的代码可以清晰的看出:

在form实例中一旦有状态的变更只需要遍历Item实例的 onStoreChange ,就可以触发 Item 组件的 update。

而form实例中调用的 onStoreChange 方法实际上是在使用 form实例 的 registerField 注册item实例后,item实例中的方法

但是还是有几个细节需要交代一下:

  1. Item实例 怎么调用 form实例 的 registerField 方法把自身给注册进去?

Item 是 Form 的子组件,在 Form 组件中通过 Context 的方法将 form 实例注入到任何层级的子组件中。所以在 Item 组件中,因为是在 Form 的包裹中,所以自然可以通过 useContext 拿到 form 实例,从而使用 registerField 将自身注册到 form 实例中。

  1. form实例中统一管理的数据如何与Item中的受控组件进行数据传递的呢?

下一节,Item与受控组件。

2.2. Item与受控组件

我们自定义受控组件或是使用通用组件,组件的 props 一般都会尽量遵循:

const { value, onChange, ... } = props

获取传入的 value 渲染组件,通过 onChange 回调函数的方式将组件变更的数据传递到上层使用。

我们来看Item组件中如何传递 value 和消费 onChange 回调的。

2.2.1. value

value比较简单,只需要Item组件中通过form实例中的方法(见上文),在数据仓库中查找对应字段的值即可。

1
2
3
4
5
ini复制代码public getValue = (...) => {
const { getFieldsValue }: FormInstance = this.props.context;
const namePath = this.getNamePath();
...
};

2.2.2. onChange

还是在Item组件中,通过form实例中的方法 dispatch,将更新的值传递到form实例中,我们一起看一下 dispatch 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
js复制代码//使用
onChange = (...args) => {
...
dispatch({
type: 'updateValue',
namePath,
value: newValue,
});
...
}


private dispatch = (action) => {
switch (action.type) {
case 'updateValue': {
const { namePath, value } = action;
this.updateValue(namePath, value);
break;
}
...
default:
// Currently we don't have other action. Do nothing.
}
};

private updateValue = (name: NamePath, value: StoreValue) => {
const namePath = getNamePath(name);
const prevStore = this.store;
this.updateStore(setValue(this.store, namePath, value));

this.notifyObservers(prevStore, [namePath], {
type: 'valueUpdate',
source: 'internal',
});
...
};

可以看到当某一个Item的值发生改变时,首先会更新form实例的数据仓库,然后看看有没有字段依赖了当前更新的字段,再通知各订阅了消息的Item实例,最后再将 value 和 onChange 注入到子组件的props中完成闭环。

  1. 源码中的设计模式

3.1. 观察者模式

3.1.1. 源码案例(见2.1.2. 订阅与通知)

3.1.2. 解析

观察者模式提供了一种对象间一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并自动更新。

结合观察者模式的特点,我们在日常的编码中:

  1. 目前很多复杂的后台编辑项,经常会有不同项联动的效果,开发者就可以考虑优先使用Form表单自带的观察者模式架构去实现联动。
  2. 后台的表单只是观察者模式的实践之一,结合观察者模式的抽象概念,有很多业务场景的功能可以考虑使用这种架构去组织代码,例如:购物车场景。有多个组件依赖数据仓库(购物车),数据仓库的变更会通知各组件。大多数的真实场景购物车的初始数据可能是由后端下发的,其实也就是一次修改数据仓库的行为,包括用户的交互。而用户的交互等行为修改数据仓库后,统一通知各订阅单位,包括上传购物车数据至后端,也可以看作是后端的对数据仓库的订阅。

在不同的场景中,观察者模式帮助实现了行为实体间的解耦,使得一个实体的变化可以通知到其他关联的实体。

3.2. 单例模式

3.2.1. 源码案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ini复制代码function useForm<Values = any>(form?: FormInstance<Values>): [FormInstance<Values>] {
const formRef = React.useRef<FormInstance>();
...

if (!formRef.current) {
if (form) {
formRef.current = form;
} else {
...
const formStore: FormStore = new FormStore(forceReRender);

formRef.current = formStore.getForm();
}
}
...
return [formRef.current];
}

3.2.2. 解析

上面的代码中通过 useForm hook,确保表单状态的唯一性,以避免不同实例之间的状态冲突。

单例模式和观察者模式在管理全局状态、资源、事件等比较适合在一起使用,两个模式的概念能够比较好的融合,观察者模式一对多的对象关系,那中心数据仓库就可以使用单例模式管理起来,提供了一种可维护和解耦的方式。

  1. 总结

在中后台的业务开发中,表单必不可少,合理的借用表单组件的设计模式来组织自己的代码结构是我们需要上的第一课。

脱离表单原理的视角,抽象后的设计模式,能让我们在未来的开发中,又多了一件趁手的武器

后续的这个中后台系列的文章都会在为大家讲解原理的基础上,深入考究在设计模式/代码结构上有哪些值得借鉴学习的地方。

还有哪些模块值得我们一起学习讨论欢迎在评论区中留言

长话短说,只讲干货,我们下期再见!

最后

📚 小茗文章推荐:

  • 手摸手教运营小姐姐搭建一个表单
  • 前端开发者需要了解的「设计体系」
  • 门店智能设备间「通信」原理

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Android 使用TextView实现验证码输入框 前言

发表于 2023-12-17

前言

网上开源的是构建同等数量的 EditText,这种存在很多缺陷,主要如下

1、数字 / 字符键盘切换后键盘状态无法保存

2、焦点切换无法判断

3、光标位置无法修正

4、切换过程需要做很多同步工作

5、需要处理聚焦选中区域问题

6、性能差

EditText越多,造成的不确定性问题将越多,因此,在开发中,如果我们自行实现一个纯View的输入框有没有可能呢?比较遗憾的是,Android 层面android.widget.Editor是非公开的类,因此很难去实现一个想要的View。

另一种方案,我们继承TextView,改写TextView的绘制逻辑也是可以。

为什么TextView是可以的呢?

  • 第一:TextView 本身可以输入任何文本
  • 第二:TextView 绘制方法中使用android.widget.Editor可以辅助keycode->文本转换
  • 第三:TextView 提供了光标等各种组件

核心步骤

为了解决上述问题,使用 TextView 实现输入框,这里需要解决的问题是

1、允许 TextView 可编辑输入,这点可以参考EditText的实现

2、重写 onDraw 实现,不实用原有的绘制逻辑。

3、重写光标逻辑,默认的光标逻辑和Editor有很多关联逻辑,而Editor是@hide标注的,因此必须要重写

4、重写长按菜单逻辑,防止弹出剪切、复制、选中等PopWindow弹窗。
5、限制文本长度

fire_89.gif

代码实现

首先我们要继承TextView或者AppCompatTextView,然后实现下面的操作

变量定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码//边框颜色
private int boxColor = Color.BLACK;

//光标是否可见
private boolean isCursorVisible = true;
//光标
private Drawable textCursorDrawable;
//光标宽度
private float cursorWidth = dp2px(2);
//光标高度
private float cursorHeight = dp2px(36);
//是否展示光标
private boolean isShowCursor;
//字符数量控制
private int inputBoxNum = 5;
//间距
private int mBoxSpace = 10;

关键状态

禁止复制、粘贴、选中

mrb62ges5a.jpeg

1
2
3
4
5
6
7
8
9
10
java复制代码super.setFocusable(true); //支持聚焦
super.setFocusableInTouchMode(true); //支持触屏模式聚焦
//可点击,因为聚焦的view必须是可以点击的,这里你也可以设置个clickListener,效果一样
super.setClickable(true);
super.setGravity(Gravity.CENTER_VERTICAL);
super.setMaxLines(1);
super.setSingleLine();
super.setFilters(inputFilters);
super.setLongClickable(false);// 禁止复制、剪切
super.setTextIsSelectable(false); // 禁止选中

绘制逻辑

我们重写onDraw方法,自行绘制View

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
java复制代码TextPaint paint = getPaint();

float strokeWidth = paint.getStrokeWidth();
if(strokeWidth == 0){
//默认Text是没有strokeWidth的,为了防止绘制边缘存在问题,这里强制设置 1dp
paint.setStrokeWidth(dp2px(1));
strokeWidth = paint.getStrokeWidth();
}
paint.setTextSize(getTextSize());

float boxWidth = (getWidth() - strokeWidth * 2f - (inputBoxNum - 1) * mBoxSpace) / inputBoxNum;
float boxHeight = getHeight() - strokeWidth * 2f;
int saveCount = canvas.save();
//获取默认风格
Paint.Style style = paint.getStyle();
Paint.Align align = paint.getTextAlign();
paint.setTextAlign(Paint.Align.CENTER);

String text = getText().toString();
int length = text.length();

int color = paint.getColor();

for (int i = 0; i < inputBoxNum; i++) {

inputRect.set(i * (boxWidth + mBoxSpace) + strokeWidth,
strokeWidth,
strokeWidth + i * (boxWidth + mBoxSpace) + boxWidth,
strokeWidth + boxHeight);

paint.setStyle(Paint.Style.STROKE);
paint.setColor(boxColor);
//绘制边框
canvas.drawRoundRect(inputRect, boxRadius, boxRadius, paint);

//设置当前TextColor
int currentTextColor = getCurrentTextColor();
paint.setColor(currentTextColor);
paint.setStyle(Paint.Style.FILL);
if (text.length() > i) {
// 绘制文字,这里我们不过滤空格,当然你可以在InputFilter中处理
String CH = String.valueOf(text.charAt(i));
int baseLineY = (int) (inputRect.centerY() + getTextPaintBaseline(paint));//基线中间点的y轴计算公式
canvas.drawText(CH, inputRect.centerX(), baseLineY, paint);
}

//绘制光标
if(i == length && isCursorVisible && length < inputBoxNum){
Drawable textCursorDrawable = getTextCursorDrawable();
if(textCursorDrawable != null) {
if (!isShowCursor) {
textCursorDrawable.setBounds((int) (inputRect.centerX() - cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f), (int) (inputRect.centerX() + cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f + cursorHeight));
textCursorDrawable.draw(canvas);
isShowCursor = true; //控制光标闪烁 blinking
} else {
isShowCursor = false;//控制光标闪烁 no blink
}
removeCallbacks(invalidateCursor);
postDelayed(invalidateCursor,500);
}
}
}

paint.setColor(color);
paint.setStyle(style);
paint.setTextAlign(align);

canvas.restoreToCount(saveCount);

InsertionHandleView问题

image.png

我们上文处理了各种可能出现的选中区域弹窗,然而一个很难处理的弹窗双击后会展示,评论区有同学也贴出来了。主要原因是Editor为了方便EditText选中,在内部使用了InsertionHandleView去展示一个弹窗,但这个弹窗并不是直接addView的,而是通过PopWindow展示的,具体可以参考下面源码。

实际上,掘金Android 客户端也有类似的问题,不过掘金app的实现方式是使用多个EditText实现的,点击的时候就会明显看到这个小雨点,其次还有光标卡顿的问题。

android.widget.Editor.InsertionHandleView

解决方法其实有3种:

第一种是Hack Context,返回一个自定义的WindowManager给PopWindow,不过我们知道InputManagerService 作为 WindowManagerService中的子服务,如果处理不当,可能产生输入法无法输入的问题,另外要Hack WindowManager,显然工作量很大。

第二种是替换:修改InsertionHandleView的背景元素,具体可参考:blog.csdn.net/shi_xin/art… 一文

1
2
3
java复制代码<item name="textSelectHandleLeft">@drawable/text_select_handle_left_material</item>
<item name="textSelectHandleRight">@drawable/text_select_handle_right_material</item>
<item name="textSelectHandle">@drawable/text_select_handle_middle_material</item>

这种方式增加了View的可扩展性,自定义View要尽可能避免和xml配置耦合,除非是自定义属性。

第三种是拦截hide方法,在popWindow展示之后,会立即设置一个定时消失的逻辑,这种相对简单,而且View的通用性不受影响,但是也有些不规范,不过目前这个调用还是相当稳定的。

综上,我们选择第三种方案,我这里直接拦截其内部调用postDelay的方法,如果是InsertionHandleView的内部类,且时间为4000秒,直接执行runnable

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码private void hideAfterDelay() {
if (mHider == null) {
mHider = new Runnable() {
public void run() {
hide();
}
};
} else {
removeHiderCallback();
}
mTextView.postDelayed(mHider, DELAY_BEFORE_HANDLE_FADES_OUT);
}

下面是解法:

1
2
3
4
5
6
7
8
9
10
java复制代码@Override
public boolean postDelayed(Runnable action, long delayMillis) {
final long DELAY_BEFORE_HANDLE_FADES_OUT = 4000;
if(delayMillis == DELAY_BEFORE_HANDLE_FADES_OUT
&& action.getClass().getName().startsWith("android.widget.Editor$InsertionHandleView$")){
Log.d("TAG","delayMillis = " + delayMillis);
delayMillis = 0;
}
return super.postDelayed(action, delayMillis);
}

总结

上面就是本文的核心逻辑,实际上EditText、Button都继承自TextView,因此我们简单的修改就能让其支持输入,主要原因还是TextView复杂的设计和各种Layout的支持,但是这也给TextView带来了性能问题。

这里简单说下TextView性能优化,对于单行文本和非可编辑文本,最好是自行实现,单行文本直接用canvas.drawText绘制,当然多行也是可以的,不过鉴于要支持很多特性,多行文本可以使用StaticLayout去实现,但单行文本尽量自己绘制,也不要使用BoringLayout,因为其存在一些兼容性问题,另外自定义的单行文本不要和TextView同一行布局,因为TextView的计算相对较多,很可能产生对不齐的问题。

本篇全部代码

按照惯例,这里依然提供全部代码,仅供参考,当然,也可以直接使用到项目中,本篇代码在线上已经使用过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
java复制代码public class EditableTextView extends TextView {

private RectF inputRect = new RectF();


//边框颜色
private int boxColor = Color.BLACK;

//光标是否可见
private boolean isCursorVisible = true;
//光标
private Drawable textCursorDrawable;
//光标宽度
private float cursorWidth = dp2px(2);
//光标高度
private float cursorHeight = dp2px(36);
//光标闪烁控制
private boolean isShowCursor;
//字符数量控制
private int inputBoxNum = 5;
//间距
private int mBoxSpace = 10;
// box radius
private float boxRadius = dp2px(0);

InputFilter[] inputFilters = new InputFilter[]{
new InputFilter.LengthFilter(inputBoxNum)
};


public EditableTextView(Context context) {
this(context, null);
}

public EditableTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public EditableTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
super.setFocusable(true); //支持聚焦
super.setFocusableInTouchMode(true); //支持触屏模式聚焦
//可点击,因为在触屏模式可聚焦的view一般是可以点击的,这里你也可以设置个clickListener,效果一样
super.setClickable(true);
super.setGravity(Gravity.CENTER_VERTICAL);
super.setMaxLines(1);
super.setSingleLine();
super.setFilters(inputFilters);
super.setLongClickable(false);// 禁止复制、剪切
super.setTextIsSelectable(false); // 禁止选中

Drawable cursorDrawable = getTextCursorDrawable();
if(cursorDrawable == null){
cursorDrawable = new PaintDrawable(Color.MAGENTA);
setTextCursorDrawable(cursorDrawable);
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
super.setPointerIcon(null);
}
super.setOnLongClickListener(new OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
return true; //抑制长按出现弹窗的问题
}
});

//禁用ActonMode弹窗
super.setCustomSelectionActionModeCallback(null);

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setBreakStrategy(LineBreaker.BREAK_STRATEGY_SIMPLE);
}
mBoxSpace = (int) dp2px(10f);

}

@Override
public ActionMode startActionMode(ActionMode.Callback callback) {
return null;
}

@Override
public ActionMode startActionMode(ActionMode.Callback callback, int type) {
return null;
}

@Override
public boolean hasSelection() {
return false;
}

@Override
public boolean showContextMenu() {
return false;
}

@Override
public boolean showContextMenu(float x, float y) {
return false;
}

public void setBoxSpace(int mBoxSpace) {
this.mBoxSpace = mBoxSpace;
postInvalidate();
}

public void setInputBoxNum(int inputBoxNum) {
if (inputBoxNum <= 0) return;
this.inputBoxNum = inputBoxNum;
this.inputFilters[0] = new InputFilter.LengthFilter(inputBoxNum);
super.setFilters(inputFilters);
}

@Override
public void setClickable(boolean clickable) {

}

@Override
public void setLines(int lines) {

}
@Override
protected boolean getDefaultEditable() {
return true;
}


@Override
protected void onDraw(Canvas canvas) {

TextPaint paint = getPaint();

float strokeWidth = paint.getStrokeWidth();
if(strokeWidth == 0){
//默认Text是没有strokeWidth的,为了防止绘制边缘存在问题,这里强制设置 1dp
paint.setStrokeWidth(dp2px(1));
strokeWidth = paint.getStrokeWidth();
}
paint.setTextSize(getTextSize());

float boxWidth = (getWidth() - strokeWidth * 2f - (inputBoxNum - 1) * mBoxSpace) / inputBoxNum;
float boxHeight = getHeight() - strokeWidth * 2f;
int saveCount = canvas.save();

Paint.Style style = paint.getStyle();
Paint.Align align = paint.getTextAlign();
paint.setTextAlign(Paint.Align.CENTER);

String text = getText().toString();
int length = text.length();

int color = paint.getColor();

for (int i = 0; i < inputBoxNum; i++) {

inputRect.set(i * (boxWidth + mBoxSpace) + strokeWidth,
strokeWidth,
strokeWidth + i * (boxWidth + mBoxSpace) + boxWidth,
strokeWidth + boxHeight);

paint.setStyle(Paint.Style.STROKE);
paint.setColor(boxColor);
//绘制边框
canvas.drawRoundRect(inputRect, boxRadius, boxRadius, paint);

//设置当前TextColor
int currentTextColor = getCurrentTextColor();
paint.setColor(currentTextColor);
paint.setStyle(Paint.Style.FILL);
if (text.length() > i) {
// 绘制文字,这里我们不过滤空格,当然你可以在InputFilter中处理
String CH = String.valueOf(text.charAt(i));
int baseLineY = (int) (inputRect.centerY() + getTextPaintBaseline(paint));//基线中间点的y轴计算公式
canvas.drawText(CH, inputRect.centerX(), baseLineY, paint);
}

//绘制光标
if(i == length && isCursorVisible && length < inputBoxNum){
Drawable textCursorDrawable = getTextCursorDrawable();
if(textCursorDrawable != null) {
if (!isShowCursor) {
textCursorDrawable.setBounds((int) (inputRect.centerX() - cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f), (int) (inputRect.centerX() + cursorWidth / 2f), (int) ((inputRect.height() - cursorHeight) / 2f + cursorHeight));
textCursorDrawable.draw(canvas);
isShowCursor = true; //控制光标闪烁 blinking
} else {
isShowCursor = false;//控制光标闪烁 no blink
}
removeCallbacks(invalidateCursor);
postDelayed(invalidateCursor,500);
}
}
}

paint.setColor(color);
paint.setStyle(style);
paint.setTextAlign(align);

canvas.restoreToCount(saveCount);
}


private Runnable invalidateCursor = new Runnable() {
@Override
public void run() {
invalidate();
}
};


//避免paint.getFontMetrics内部频繁创建对象
Paint.FontMetrics fm = new Paint.FontMetrics();

/**
* 基线到中线的距离=(Descent+Ascent)/2-Descent
* 注意,实际获取到的Ascent是负数。公式推导过程如下:
* 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
*/

public float getTextPaintBaseline(Paint p) {
p.getFontMetrics(fm);
Paint.FontMetrics fontMetrics = fm;
return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
}

/**
* 控制是否保存完整文本
*
* @return
*/
@Override
public boolean getFreezesText() {
return true;
}

@Override
public Editable getText() {
return (Editable) super.getText();
}

@Override
public void setText(CharSequence text, BufferType type) {
super.setText(text, BufferType.EDITABLE);
}

/**
* 控制光标展示
*
* @return
*/
@Override
protected MovementMethod getDefaultMovementMethod() {
return ArrowKeyMovementMethod.getInstance();
}

@Override
public boolean isCursorVisible() {
return isCursorVisible;
}

@Override
public void setTextCursorDrawable(@Nullable Drawable textCursorDrawable) {
// super.setTextCursorDrawable(null);
this.textCursorDrawable = textCursorDrawable;
postInvalidate();
}

@Nullable
@Override
public Drawable getTextCursorDrawable() {
return textCursorDrawable; //支持android Q 之前的版本
}

@Override
public void setCursorVisible(boolean cursorVisible) {
isCursorVisible = cursorVisible;
}
public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}

public void setBoxRadius(float boxRadius) {
this.boxRadius = boxRadius;
postInvalidate();
}

public void setBoxColor(int boxColor) {
this.boxColor = boxColor;
postInvalidate();
}

public void setCursorHeight(float cursorHeight) {
this.cursorHeight = cursorHeight;
postInvalidate();
}

public void setCursorWidth(float cursorWidth) {
this.cursorWidth = cursorWidth;
postInvalidate();
}

@Override
public boolean postDelayed(Runnable action, long delayMillis) {
final long DELAY_BEFORE_HANDLE_FADES_OUT = 4000;
if(delayMillis == DELAY_BEFORE_HANDLE_FADES_OUT
&& action.getClass().getName().startsWith("android.widget.Editor$InsertionHandleView$")){
delayMillis = 0;
}
return super.postDelayed(action, delayMillis);
}

}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

工作6年的程序员,如何挖掘内心,找到自己想要的东西

发表于 2023-12-14

前言

Hi,大家好,我是DY拿铁,一名95后奶爸程序员。
2023年,如果用一个词来形容,那一定是迷茫。今天想和大家聊聊,程序员如何在自己的职业生涯中去寻求破局,自己的一些思考。

为什么会思考这些

2022年,为了迎接我的孩子,照顾家庭,从大厂辞职回到济南,进入到了一家传统行业的信息公司。薪资、福利都有了不少的下降。但更让我焦虑的是,职业生涯的发展也明显有了很大的限制,当了爸爸之后,心态、生活上也发生了很大的变化,为了给家庭、给孩子更好的未来,在大环境下寻求突破,是我想迫切解决的问题。

我的经历

今年已经是我工作的第6个年头,从一名懵懂的大学毕业生,成长为了现在的职场打工仔。6年间,我从济南到北京,再从北京回到济南。呆过传统行业,去过互联网行业。从小公司,一步步跳槽到大公司。中间的经历,虽算不上精彩,但也可以说的上丰富。

我过去的成长路径,是一步一个脚印,认真学习八股文、算法、架构。拿到曾经期望的薪资,进入梦寐以求的大厂。我也很感激过去的自己。让自己能够在家乡买房,让我结婚生子,成家立业。

落差

随着经济下行,互联网企业裁员不断,程序员行业日渐趋于饱和,各行各业的日子都不好过。

互联网企业,想要向上发展,一个是技术方向,一个是业务方向。

  • 技术方向,我是做后端的,其实大厂的后端无非就是拧螺丝,技术架构扎实,相关组件丰富。更多的还是去做工程方面,技术方面向上突破是很艰难的,几乎看不到更多的成长路径。
  • 做业务的话,其实就是在一定阶段,转向偏管理方向,带着一个小团队或者大团队,去拿到业务结果。需要不断去努力、去拼、去卷,才能有一些机会去做好的方向,才有几户去拿到结果。

而传统企业更多的还是人情社会,你的能力不再是领导关注的最主要的因素,即使你能力强,但在领导眼中可能比不过那些在公司干了5年、10年的老员工,传统企业,稳定、忠诚很重要,所以技术的天花板很低,只能走业务方向,转向管理。

思考

迷茫之下,无意间通过直播,了解到了三叶草模型。“三叶草”模型是什么
三叶草.jpg

每个人都希望自己的工作状态是理想的,要想达到这个目标,就得想清楚当我们在谈论工作的时候我们在谈论什么。

工作的本质是,通过价值创造完成价值兑现。我们能够进行价值创造的前提是对所从事的工作内容有兴趣,兴趣加上持续的投入,让我们具备完成工作所需要的能力。通过能力创造价值,我们可以获得物质回报和精神回报,这些东西让我们感受到价值感和满足感,从而有更强的兴趣投入到工作中,这样形成一个闭环。

所以完美的工作应该包含兴趣、能力、价值三个方面。

  1. 我们感兴趣的;
  2. 我们有能力胜任的;
  3. 能够回馈给我们价值的,与我们当下所追求的价值观相符的。

当以上三个方面都获得满足时,我们体验到的是快乐、对工作有热情、有成就感和掌控感、觉得工作有意义、有价值。相反,如果当某一方面得不到满足时,我们会产生负面的体验和情绪。

三叶草模型,可以通过我们在工作中表现出的情绪状态,来帮助我们进行自我觉察,或者帮助别人判断当下职业发展的状态是什么,哪里存在问题,应该从何处着手解决问题,促进发展。

反思

通过三叶草模型发现,自己逐渐出现了兴趣缺失的问题,长时间在后端开发岗位,早早度过了起步适应阶段,加上从北京回到济南,工作难度大幅降低,因此催生了较大的厌倦情绪。

我也渐渐发现,过去一步一个脚印的思维,已经不在适用了。那时的我在技术领域是一个小白,有着数不清的技术要去学习与了解,有着大公司等着我去尝试,所以我可以有清晰的目标,也能够拿到及时的反馈。但现在,纯粹的开发,几乎很少再会遇到能力问题了,重复的工作,也不会再有正反馈。

年龄持续增长,天花板似乎就在眼前,难道作为程序员,我们就真的没有第三种方向让我们去选择了吗?

寻求破局

经过这一段时间的思考,摆在我眼前的有几条路

1. 持续深挖Java技术路线,卷职场,积攒年限,发展管理方向

  • 优势:熟悉的领域,熟悉的方向,多年的积累,技术方面没有问题,但需要持续学习管理知识
  • 劣势:依赖公司发展与人际关系,且这个道路比较卷,毕竟是僧多粥少,成为架构师,当上CTO,也是大家美好的想法

2. 更换技术方向,研究AI,顺应时代趋势

  • 优势:技术风口,顺应时代趋势,AI领域今年只不过是刚刚起步,后续发展上限高,技术领域更新换代很快,AI很有可能会持续发展并释放出更多的生产力
  • 劣势:0基础,不确定自己能够达到的上限在哪里

3. 拓宽赚钱思维,寻求副业可能,做一些除了开发的小生意

  • 优势:打开思路,扩大眼界, 越来越意识到,如果只把自己限制在程序员领域,往后的发展会非常受限,即使有风口,自己也赶不上
  • 劣势:可能要花比较多的时间,去试错。对主业有一定影响

上述几个方向,在去实践中,会有非常大的不同,作为普通人,我真的很难去选择,每一条路都会有焦虑,也都有机会,有的更稳妥,有的更激进,结果如何,我们完全无法去预知。

但无论哪个方向,想要发展好,都有一个可以去做的点,那就是持续写作,持续分享,打造个人IP,增加影响力。我个人其实是一个很喜欢写点东西的人,大学期间买过一本手帐,里面断断续续记录了自己几年来的一些看法与日记。在自己的印象笔记中,也写过不少自己的心得。但毕业这些年来,我所记录的许多东西,却没有记录在互联网的任何平台上。写作的好处有很多,比如沉淀自己的知识、经历,比如能够通过输出,倒逼自己输入等等,我也是慢慢才发现,原来写作的好处有很多。大家或许都或多或少的了解过技术方面的几个知名博主,Guide、三太子这些,他们正是把各种知识记录、整理了下来,在网上收获了粉丝,也增大了自己的个人影响力。

当然这件事情说起来容易,做起来却很难,就像我正在写的这篇文章,我几乎用了两天的时间去写作,去打磨,总是感觉不能很好的表达我自己的想法。是的,写作是一件需要坚持、刻意练习的一件事情。标题、文章结构、文章内容、文章配图、是否通顺,每一个点都有很多需要思考和学习的地方。

写在最后

说了很多,把我最近一段时间的疑惑与思考,终于算是勉强的表达出来了。之所以有这篇文章,也是在这个浮躁的社会中,让自己冷静下来,好好想想自己该做些什么,而不是被纷繁的互联网所影响,眼红那些大V,羡慕别人的生活,别人的路未必适合自己。找到自己喜欢的与想去做的,并坚持去做,走出属于自己独特的道路。
如果大家有更好的想法与意见,欢迎在评论区指出与交流。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…676869…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%