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

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


  • 首页

  • 归档

  • 搜索

纯血鸿蒙创业大赛 掘金

发表于 2024-04-23

@TOC

在这里插入图片描述
欢迎关注微信公众号:数据科学与艺术 作者WX:superhe199

名称:HabitAI

功能:

  1. 晨间日记:每天早上用户可以记录当前的心情、目标、计划等内容,用于后续的复盘和分析。
  2. 代办:用户可以创建代办事项,并设置优先级和截止日期,以帮助他们更好地管理日常任务。
  3. 目标:用户可以设定人生使命、年目标、月目标和周目标,以帮助他们有个清晰的方向和计划。
  4. 习惯记录:用户可以记录每天的习惯执行情况,如锻炼、阅读、学习等,以形成健康的生活习惯。
  5. 番茄钟:用户可以使用番茄钟技术来提高工作效率,设定工作时间和休息时间,并进行倒计时。

一个实现番茄钟的简单代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
python复制代码import time

def pomodoro_timer(work_minutes, rest_minutes):
print("Work for {} minutes".format(work_minutes))
time.sleep(work_minutes * 60) # 将分钟转换为秒,并进行倒计时

print("Rest for {} minutes".format(rest_minutes))
time.sleep(rest_minutes * 60) # 将分钟转换为秒,并进行倒计时

work_duration = 25 # 工作时间(单位:分钟)
rest_duration = 5 # 休息时间(单位:分钟)

for _ in range(4): # 进行四个番茄钟循环
pomodoro_timer(work_duration, rest_duration)

使用了 time 模块中的 sleep 函数,以秒为单位进行倒计时。pomodoro_timer 函数接受两个参数:work_minutes(工作时间)和 rest_minutes(休息时间),首先输出工作时间,然后休眠指定的工作时间。接着输出休息时间,并休眠指定的休息时间。

在主程序中,我们设定了工作时间为 25 分钟,休息时间为 5 分钟,并使用一个循环来进行四个番茄钟的计时。您可以根据需要自定义工作时间和休息时间,以及循环次数。

AI功能:

  1. 目标复盘:AI会根据用户的日记进行复盘分析,并提供反馈和建议,帮助用户更好地理解自己的行为和进步。
  2. 代办建议:AI会根据代办任务的优先级和截止日期,提供建议和监督,帮助用户更好地管理任务。
  3. 习惯执行监督和建议:AI会根据用户的习惯记录,监督和提供建议,帮助用户养成良好的习惯。
  4. 激励语:AI会根据用户的目标和进度,提供激励和支持的话语,鼓励用户坚持努力。

目标复盘的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
python复制代码def goal_review(diary):
# 根据日记内容进行复盘分析
# 分析用户是否达到了目标,并给出反馈和建议
# 返回复盘结果
if diary == "":
return "请先写下今天的日记"
else:
# 进行复盘分析
analysis = analyze_diary(diary)
feedback = generate_feedback(analysis)
suggestions = generate_suggestions(analysis)
return f"复盘结果:{feedback}。\n建议:{suggestions}"

代办建议的代码实现:

1
2
3
4
5
6
7
8
9
10
python复制代码def todo_suggestions(todos):
# 根据代办任务的优先级和截止日期进行建议和监督
# 返回建议结果
if len(todos) == 0:
return "当前没有待办任务"
else:
# 进行建议和监督
suggestions = generate_suggestions(todos)
supervision = generate_supervision(todos)
return f"建议:{suggestions}\n监督:{supervision}"

习惯执行监督和建议的代码实现:

1
2
3
4
5
6
7
8
9
10
python复制代码def habit_advice(habits):
# 根据用户的习惯记录进行监督和建议
# 返回建议结果
if len(habits) == 0:
return "当前没有习惯记录"
else:
# 进行监督和建议
supervision = generate_supervision(habits)
advice = generate_advice(habits)
return f"监督:{supervision}\n建议:{advice}"

激励语的代码实现:

1
2
3
4
5
6
7
8
9
python复制代码def motivation(goal, progress):
# 根据用户的目标和进度,提供激励和支持的话语
# 返回激励语
if goal == "":
return "请先设置目标"
else:
# 根据进度生成激励语
motivation = generate_motivation(goal, progress)
return motivation

鸿蒙功能:

  1. 多屏协同:用户可以在多个设备上同步数据和使用功能,实现更好的使用体验。
  2. 手表同步睡眠习惯:用户可以通过手表记录睡眠习惯,以帮助他们了解自己的睡眠质量。
  3. 手机服务卡片:提供快速记录、执行番茄钟、展示目标和激励语等功能,方便用户在手机上快速使用。
  4. 提醒:通过鸿蒙的提醒功能,定时提醒用户进行习惯记录、复盘和代办任务。
  5. 侧端模型:利用鸿蒙的侧端模型,对用户的数据进行分析和处理,保护用户的隐私和数据安全。
  6. 日历联动:将用户的目标、习惯和代办任务与手机日历进行联动,方便用户在日常生活中查看和调整。

代码实现

在鸿蒙开发中,可以使用多个组件来实现手机服务卡片的功能,包括Text、Button、Timer、Image等。以下是一个简单的示例,演示了如何实现快速记录、执行番茄钟、展示目标和激励语等功能。

  1. 在xml布局文件中添加所需的组件:
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
xml复制代码<?xml version="1.0" encoding="utf-8"?>
<DirectionalLayout
xmlns:ohos="http://schemas.huawei.com/res/ohos"
ohos:height="match_parent"
ohos:width="match_parent">

<!-- 快速记录 -->
<TextField
ohos:height="50vp"
ohos:width="match_parent"
ohos:hint="输入记录"
ohos:input-type="textPersonName" />

<!-- 执行番茄钟 -->
<Button
ohos:height="50vp"
ohos:width="match_parent"
ohos:text="开始番茄钟"
ohos:id="$+id/pomodoro_button" />

<!-- 目标展示 -->
<Text
ohos:height="wrap_content"
ohos:width="match_parent"
ohos:text="今日目标:学习鸿蒙开发" />

<!-- 激励语展示 -->
<Text
ohos:height="wrap_content"
ohos:width="match_parent"
ohos:text="加油,你可以的!" />

</DirectionalLayout>
  1. 在Java代码中设置按钮点击事件和番茄钟功能:
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
java复制代码package com.example.myapplication;

import ohos.aafwk.ability.Ability;
import ohos.aafwk.content.Intent;
import ohos.agp.components.Button;

public class MainAbility extends Ability {

private Button pomodoroButton;

@Override
public void onStart(Intent intent) {
super.onStart(intent);
super.setUIContent(ResourceTable.Layout_ability_main);

// 获取番茄钟按钮
pomodoroButton = (Button) findComponentById(ResourceTable.Id_pomodoro_button);

// 设置番茄钟按钮点击事件
pomodoroButton.setClickedListener(component -> startPomodoro());

}

// 执行番茄钟
private void startPomodoro() {
// 在此处添加番茄钟功能的代码
}
}
  1. 添加具体的番茄钟功能代码:

可以使用Timer类来实现番茄钟功能,如下所示:

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
java复制代码import ohos.global.resource.NotExistException;
import ohos.global.resource.Resource;
import ohos.global.resource.ResourceManager;
import ohos.global.resource.WrongTypeException;
import ohos.hiviewdfx.HiLog;
import ohos.hiviewdfx.HiLogLabel;
import ohos.hiviewdfx.HiLogType;
import ohos.timer.Timer;
import ohos.utils.zson.ZSONObject;

import java.io.IOException;

public class MainAbility extends Ability {

private static final HiLogLabel LABEL_LOG = new HiLogLabel(HiLog.LOG_APP, 0x00101, "MY_TAG");

private Button pomodoroButton;

@Override
public void onStart(Intent intent) {
super.onStart(intent);
super.setUIContent(ResourceTable.Layout_ability_main);

// 获取番茄钟按钮
pomodoroButton = (Button) findComponentById(ResourceTable.Id_pomodoro_button);

// 设置番茄钟按钮点击事件
pomodoroButton.setClickedListener(component -> startPomodoro());

}

// 执行番茄钟
private void startPomodoro() {
Timer timer = new Timer();

// 设置番茄钟时间(25分钟)
long pomodoroTime = 25 * 60 * 1000;

// 设置休息时间(5分钟)
long restTime = 5 * 60 * 1000;

// 启动番茄钟
timer.startTimer(pomodoroTime, (timerId, millSeconds) -> {
// 通过HiLog打印番茄钟剩余时间
HiLog.debug(LABEL_LOG, "Pomodoro timer: " + millSeconds / 1000 + "s");
});

// 启动休息时间
timer.startTimer(pomodoroTime + restTime, (timerId, millSeconds) -> {
// 通过HiLog打印休息时间剩余时间
HiLog.debug(LABEL_LOG, "Rest timer: " + millSeconds / 1000 + "s");
});
}
}

解决的问题:

HabitAI旨在帮助现代社会上进但容易沉迷于虚无事物的青年群体。通过集合晨间日记、代办、目标、习惯记录和番茄钟等功能,以及AI的智能分析和反馈,帮助用户培养良好的习惯和达成人生目标。与其他单一功能的APP不同,HabitAI提供综合且智能化的解决方案,帮助用户在信息爆炸的时代更好地管理自己的时间和行为,实现个人成长和进步。

本文转载自: 掘金

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

Android 80 只有全屏不透明活动可以请求方向问题

发表于 2024-04-23

Android 8.0 只有全屏不透明活动可以请求方向问题

1 背景

  • Android 8.0,即 sdk 为 26 时,Android 为了支持全面屏系统增加了一个限制,如果是透明的 Activity,则不能固定它的方向,因为它的方向其实是依赖其父 Activity 的(因为透明);
  • 因此产生了一个系统级别的 Bug,当以下四个条件同时满足时会发生的崩溃:
  • 1)使用的是 Android 8.0 操作系统的设备;
  • 2)targetSdkVersion 设置为 27 以上;
  • 3)将背景设置为透明主题;
  • 4)固定屏幕方向,screenOrientation 的值为 portrait 或者 landscape(代码或者清单文件);
  • 崩溃信息如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码Caused by: java.lang.IllegalStateException: Only fullscreen opaque activities can request orientation
at android.app.Activity.onCreate(Activity.java:1081)
at android.support.v4.app.SupportActivity.onCreate(SupportActivity.java:66)
at android.support.v4.app.FragmentActivity.onCreate(FragmentActivity.java:297)
at android.support.v7.app.AppCompatActivity.onCreate(AppCompatActivity.java:84)
at xxx.xxx.xxx.ui.XxxActivity.onCreate(XxxActivity.java:43)
at android.app.Activity.performCreate(Activity.java:7372)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1218)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3147)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3302)
at android.app.ActivityThread.-wrap12(Unknown Source:0)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1891)
at android.os.Handler.dispatchMessage(Handler.java:108)
at android.os.Looper.loop(Looper.java:166)

2 分析

  • 通过 Only fullscreen opaque activities can request orientation 报错信息可知,这是 Android 8.0 Activit 的源码所抛出的异常错误信息,源码如下所示:
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
java复制代码public class Activity {

protected void onCreate(@Nullable Bundle savedInstanceState) {
// ...
if (getApplicationInfo().targetSdkVersion >= O_MR1 && mActivityInfo.isFixedOrientation()) {
final TypedArray ta = obtainStyledAttributes(com.android.internal.R.styleable.Window);
final boolean isTranslucentOrFloating = ActivityInfo.isTranslucentOrFloating(ta);
ta.recycle();

if (isTranslucentOrFloating) {
throw new IllegalStateException(
"Only fullscreen opaque activities can request orientation");
}
}
// ...
}

}

public class ActivityInfo {

public boolean isFixedOrientation() {
return isFixedOrientationLandscape() || isFixedOrientationPortrait()
|| screenOrientation == SCREEN_ORIENTATION_LOCKED;
}

public static boolean isTranslucentOrFloating(TypedArray attributes) {
final boolean isTranslucent =
attributes.getBoolean(com.android.internal.R.styleable.Window_windowIsTranslucent,
false);
final boolean isSwipeToDismiss = !attributes.hasValue(
com.android.internal.R.styleable.Window_windowIsTranslucent)
&& attributes.getBoolean(
com.android.internal.R.styleable.Window_windowSwipeToDismiss, false);
final boolean isFloating =
attributes.getBoolean(com.android.internal.R.styleable.Window_windowIsFloating,
false);

return isFloating || isTranslucent || isSwipeToDismiss;
}

}
  • 通过阅读上述的源码可知,触发 IllegalStateException 异常的条件有:
  • 1)Activity 屏幕方向固定,windowIsTranslucent = true;
  • 2)Activity 屏幕方向固定,windowIsTranslucent = false, windowSwipeToDismiss = true;
  • 3)Activity 屏幕方向固定,windowIsFloating = true。

3 解决思路

  • 1)[不推荐] 暴力回退 sdk 版本,即 sdk <= 26;
  • 2)[不推荐] 去除主题中的透明属性,需求允许的话:
1
xml复制代码<item name="android:windowIsTranslucent">false</item>
  • 3)[不推荐] 指定除 8.0 以外的系统固定屏幕方向,去掉清单文件中 screenOrientation 属性,activity 中 onCreate 中执行屏幕方向固定的代码:
1
2
3
4
java复制代码if (android.os.Build.VERSION.SDK_INT != android.os.Build.VERSION_CODES.O) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
// setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
}
  • 4)[不推荐] 如果你前一个页面和需要透明主题的界面屏幕方向一致,我们只需要在清单文件中配置 android:screenOrientation="behind",behind 的意思就是和之前页面的屏幕方向保持一致;
  • 5)[推荐] 通过反射,让系统绕过屏幕方向的检测,设置屏幕不固定:
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
kotlin复制代码open class FixOreoOrientationActivity: AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
// 必须在 Activity#onCreate() 中 super 之前调用
fixOrientationBugForAndroidO()
super.onCreate(savedInstanceState)
}

/**
* 针对 Android 8.0 版本,如果 Activity 是透明或浮动的,则尝试修复屏幕方向设置的问题
* 异常日志如下:
* Caused by: java.lang.IllegalStateException: Only fullscreen opaque activities can request orientation
* at android.app.Activity.onCreate(Activity.java:1081)
* at android.support.v4.app.SupportActivity.onCreate(SupportActivity.java:66)
* at android.support.v4.app.FragmentActivity.onCreate(FragmentActivity.java:297)
* at android.support.v7.app.AppCompatActivity.onCreate(AppCompatActivity.java:84)
* at xxx.xxx.xxx.ui.XxxActivity.onCreate(XxxActivity.java:43)
* at android.app.Activity.performCreate(Activity.java:7372)
* at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1218)
* at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3147)
* at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3302)
* at android.app.ActivityThread.-wrap12(Unknown Source:0)
* at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1891)
* at android.os.Handler.dispatchMessage(Handler.java:108)
* at android.os.Looper.loop(Looper.java:166)
*/
private fun fixOrientationBugForAndroidO() {
if (Build.VERSION_CODES.O == Build.VERSION.SDK_INT && isTranslucentOrFloating()) {
fixOrientation()
}
}

/**
* 通过反射获取 ActivityInfo,并尝试修改 screenOrientation 属性以避免崩溃
* @return Boolean 是否成功修复 [true:成功 false:失败]
*/
@SuppressLint("DiscouragedPrivateApi")
private fun fixOrientation(): Boolean {
return try {
val field: Field = Activity::class.java.getDeclaredField("mActivityInfo")
field.isAccessible = true
val activityInfo: ActivityInfo = field.get(this) as ActivityInfo
// 设置屏幕方向不固定
activityInfo.screenOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
field.isAccessible = false
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}

/**
* 通过反射检查当前 Activity 是否为透明或浮动
* @return Boolean 是否为透明或浮动 [true:是透明或浮动 false:不是透明或浮动]
*/
@SuppressLint("PrivateApi")
private fun isTranslucentOrFloating(): Boolean {
return try {
val styleableClass: Class<*> = Class.forName("com.android.internal.R\$styleable")
val windowField: Field = styleableClass.getField("Window")
val styleableAttrs: IntArray = windowField.get(null) as IntArray
val typedArray: TypedArray = obtainStyledAttributes(styleableAttrs)

val activityInfoClass: Class<*> = ActivityInfo::class.java
val method: Method = activityInfoClass.getMethod("isTranslucentOrFloating", TypedArray::class.java)
method.isAccessible = true
val isTranslucentOrFloating: Boolean = method.invoke(null, typedArray) as Boolean
method.isAccessible = false
typedArray.recycle()
isTranslucentOrFloating
} catch (e: Exception) {
e.printStackTrace()
false
}
}

/**
* 重写设置屏幕方向的方法
* 以便在 Android 8.0 且 Activity 为透明或浮动时,阻止设置屏幕方向
* @param orientation 屏幕方向
*/
override fun setRequestedOrientation(orientation: Int) {
if (Build.VERSION_CODES.O != Build.VERSION.SDK_INT || ! isTranslucentOrFloating()) {
super.setRequestedOrientation(orientation)
}
}
}
  • 注意:
  • 上述的 FixOreoOrientationActivity 代码为反编译懂车帝 Android 代码中获取,由此可见在应用市场已得到有效验证;
  • 其次因为上代码涉及到反射,这些 API 在未来的 Android 版本中可能会改变或不再可访问,这可能导致你的应用在未来的 Android 版本上出现问题,所以需要关注 Android 版本升级相关变更信息,从而持续维护该方法。

本文转载自: 掘金

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

写给前端的 Docker 入门教程 虚拟机 Linux 容器

发表于 2024-04-23

在软件开发过程中,环境配置是一个至关重要的步骤,它不仅影响开发效率,也直接关联到软件的最终质量。正确的环境配置可以极大地减少开发中的潜在问题,提升软件发布的流畅度和稳定性。以下是几个关键方面,以及如何优化环境配置的策略:

在多数项目中,通常至少会设置以下几种环境:

  1. 本地开发环境:开发人员的个人计算机或者开发服务器。
  2. 测试环境:用来运行自动化测试,模拟生产环境的设置以检测问题。
  3. 预发布(Staging)环境:一个模拟真实生产环境的设置,用于最终的测试和质量保证。
  4. 生产环境:实际用户使用的环境,需要高度的稳定和安全。

目前所面临的主要挑战主要有以下几个方面:

  1. 一致性问题:保持各环境间配置的一致性是关键,尤其是从测试到生产环境的转换过程中。
  2. 环境隔离性:确保高级环境的操作不影响到生产环境,防止数据泄露或服务中断。
  3. 版本控制和依赖管理:软件依赖和第三方服务的版本不一致可能引发环境间行为差异。
  4. 依赖冲突:依赖库之间的兼容性问题,尤其是子依赖的版本冲突。

在接下来的内容我们将一步步讲解,最终引出 Docker。

虚拟机

虚拟机(virtual machine)就是带环境安装的一种解决方案。它可以在一种操作系统里面运行另一种操作系统,比如在 Windows 系统里面运行 Linux 系统。应用程序对此毫无感知,因为虚拟机看上去跟真实系统一模一样,而对于底层系统来说,虚拟机就是一个普通文件,不需要了就删掉,对其他部分毫无影响。

虚拟机大致可以分为两类:

  1. 系统虚拟机:这种虚拟机提供了一个完整的系统平台,支持执行完整的操作系统。系统虚拟机可以允许多个操作系统实例同时在同一硬件上运行,彼此完全隔离。这种虚拟机的例子包括 VMware ESXi、Microsoft Hyper-V 和 Oracle VirtualBox。
  2. 进程虚拟机:这种虚拟机为单个程序提供了一个执行环境,它仿佛是在完全独立的机器上运行。进程虚拟机的一个典型例子是 Java 虚拟机(JVM),它允许运行 Java 程序,程序与底层操作系统和硬件是独立的。

虽然用户可以通过虚拟机还原软件的原始环境。但是,这个方案有几个缺点。

  1. 资源占用多:虚拟机会独占一部分内存和硬盘空间。它运行的时候,其他程序就不能使用这些资源了。哪怕虚拟机里面的应用程序,真正使用的内存只有 1MB,虚拟机依然需要几百 MB 的内存才能运行。
  2. 冗余步骤多:虚拟机是完整的操作系统,一些系统级别的操作步骤,往往无法跳过,比如用户登录。
  3. 启动慢:启动操作系统需要多久,启动虚拟机就需要多久。可能要等几分钟,应用程序才能真正运行。

Linux 容器

由于虚拟机存在这些缺点,Linux 发展出了另一种虚拟化技术:Linux 容器(Linux Containers,缩写为 LXC)。

Linux 容器不是模拟一个完整的操作系统,而是对进程进行隔离。或者说,在正常进程的外面套了一个保护层。对于容器里面的进程来说,它接触到的各种资源都是虚拟的,从而实现与底层系统的隔离。

由于容器是进程级别的,相比虚拟机有很多优势。

  1. 启动快:容器里面的应用,直接就是底层系统的一个进程,而不是虚拟机内部的进程。所以,启动容器相当于启动本机的一个进程,而不是启动一个操作系统,速度就快很多。
  2. 资源占用少:容器只占用需要的资源,不占用那些没有用到的资源;虚拟机由于是完整的操作系统,不可避免要占用所有资源。另外,多个容器可以共享资源,虚拟机都是独享资源。
  3. 体积小:容器只要包含用到的组件即可,而虚拟机是整个操作系统的打包,所以容器文件比虚拟机文件要小很多

总之,容器有点像轻量级的虚拟机,能够提供虚拟化的环境,但是成本开销小得多。

Docker 是什么?

Docker 是一个开源的容器化平台,它允许开发者打包应用及其依赖项到一个可移植的容器中,这些容器可以在任何支持 Docker 的机器上运行,确保了环境的一致性和应用的快速部署。Docker 使用 Linux 容器(LXC)技术,但它提供了比传统 LXC 更高级、更易用的功能集合。

Docker 将应用程序与该程序的依赖,打包在一个文件里面。运行这个文件,就会生成一个虚拟容器。程序在这个虚拟容器里运行,就好像在真实的物理机上运行一样。有了 Docker,就不用担心环境问题。
总体来说,Docker 的接口相当简单,用户可以方便地创建和使用容器,把自己的应用放入容器。容器还可以进行版本管理、复制、分享、修改,就像管理普通的代码一样。

Docker 镜像(Images)

Docker 镜像是构建 Docker 容器的基础,它是一个轻量级、可执行的独立软件包,包含运行某个软件所需的所有代码、运行时环境、库、环境变量和配置文件。Docker 镜像一旦创建,即处于不变状态(immutable),不会随着容器的启动和停止而改变。镜像可以被视为容器的 蓝图,每当从镜像启动容器时,Docker 会使用该镜像创建一个新的容器。

image 文件是通用的,一台机器的 image 文件拷贝到另一台机器,照样可以使用。一般来说,为了节省时间,我们应该尽量使用别人制作好的 image 文件,而不是自己制作。即使要定制,也应该基于别人的 image 文件进行加工,而不是从零开始制作。

为了方便共享,image 文件制作完成后,可以上传到网上的仓库。Docker 的官方仓库 Docker Hub 是最重要、最常用的 image 仓库。此外,出售自己制作的 image 文件也是可以的。

Docker 的用途主要有以下几个方面:

  1. 提供一次性的环境。比如,本地测试他人的软件、持续集成的时候提供单元测试和构建的环境。
  2. 微服务架构:Docker 非常适合微服务架构,每个服务可以独立容器化,彼此隔离而又能轻松协作。
  3. 提供弹性的云服务。因为 Docker 容器可以随开随关,很适合动态扩容和缩容。

Docker 的这些用途使得它成为在日常软件开发中不可或缺的工具,特别是在后端,就显得更为常见了,特别是在实现快速、一致且高效的开发和部署流程方面。

Docker 镜像的核心概念主要有以下几个方面:

  1. 分层存储:Docker 镜像采用了分层存储的架构。镜像由多个只读层组成,每一层对应镜像构建过程中的一组改动。例如,安装一个软件包会创建一个新的层,更新配置文件会创建另一个新的层。这种分层架构的好处是重用和共享,不同的镜像可以共享相同的层,这不仅减少了存储空间,还可以加速镜像的下载和传输。

就好像我们的房子,共用一个地基,但是每一层都是可以装修成不一样的风格。

  1. 联合文件系统(Union File System):联合文件系统是支持镜像分层的技术。它允许将多个不同的文件系统挂载到同一个工作目录,使得它们看起来像是一个单一的文件系统。这样,Docker 可以将各个层叠加起来,形成最终的文件系统。
  2. 镜像标签(Tags):Docker 镜像可以通过标签进行标识,这类似于软件版本号。标签允许开发者发布镜像的不同版本,便于管理和选择特定的版本进行部署。

20240423163420

Dockerfile 文件

Dockerfile 是一个文本文件,其中包含了一系列的指令和参数,这些指令和参数被 Docker 用来自动化地构建 Docker 镜像。每一个 Dockerfile 指令都会在镜像中创建一个新的层,这些层一起定义了镜像的最终内容和配置。

它描述了镜像构建的完整过程,包括基础镜像的选择、在构建过程中执行的命令(如安装软件、复制文件等)、设置的环境变量、暴露的端口等。

Dockerfile 和 Docker 镜像(image)之间的关系非常紧密,可以理解为 Dockerfile 是构建 Docker 镜像的“配方”或“蓝图”。

当我们在运行 docker build 命令并执行一个包含 Dockerfile 的目录时,Docker 会读取这个 Dockerfile,并按照里面定义的指令逐步构建一个新的 Docker 镜像。

因此,Dockerfile 和 Docker 镜像的关系可以总结为:Dockerfile 是镜像的构建脚本,而镜像是这个脚本的最终产物。

Dockerfile 和 Node.js 的 package.json 文件虽然用途不同,但确实有一些相似之处,特别是在自动化和标准化配置的方面。

它们的共同点主要有以下几个方面:

  1. 两者都用于自动化环境的搭建,Dockerfile 自动化容器的构建,而 package.json 自动化 Node.js 项目的依赖安装。
  2. 配置标准化:两者都通过文本文件定义项目或环境的配置,确保了不同环境下的一致性。
  3. 重复使用:通过版本控制,这些文件可以在不同项目中重用,减少了配置工作并提高了效率。

Dockerfile 的基本结构和指令

FROM 指令定义了镜像的基础,即使用哪个现有镜像作为起点。通常,这是一个已经包含了操作系统基础设施和一些预安装软件的镜像。

1
dockerfile复制代码FROM ubuntu:18.04

RUN 指令用来执行命令行命令。它在当前镜像层上执行命令,并创建新的层。这常用于安装软件包、修改文件和其他构建任务。

1
sql复制代码RUN apt-get update && apt-get install -y nginx

CMD 指令提供了容器启动时默认执行的命令。如果 Docker 容器启动时没有指定其他命令,那么就会执行 CMD 指令中的命令。一个 Dockerfile 中只能有一个 CMD 指令。

1
css复制代码CMD ["nginx", "-g", "daemon off;"]

EXPOSE 指令用于指定容器在运行时监听的端口。

1
复制代码EXPOSE 80

ENV 指令用于设置环境变量。这些环境变量可以在构建过程中使用,也会被嵌入到构建的镜像中,可用于运行时配置。

1
复制代码ENV NGINX_VERSION 1.14

ADD 和 COPY 是用来从构建环境复制文件到镜像中的。COPY 通常用于复制本地文件到镜像中,而 ADD 有额外的功能,比如自动解压压缩文件和从 URL 下载文件。

1
2
bash复制代码COPY ./app /usr/src/app
ADD http://example.com/big.tar.xz /usr/src/thirdparty

ENTRYPOINT 指令用于为容器配置一个容器启动时默认执行的命令,它可以与 CMD 指令搭配使用,以定义可传递给 ENTRYPOINT 的默认参数。

1
2
css复制代码ENTRYPOINT ["nginx", "-g", "daemon off;"]
CMD ["-c", "/etc/nginx/nginx.conf"]

WORKDIR 指令用于设置在 Docker 容器内的工作目录。后续的 RUN, CMD, ENTRYPOINT, COPY, ADD 指令都会在这个目录下执行。

1
bash复制代码WORKDIR /usr/src/app

USER 指令用于指定运行容器内进程的用户身份。

1
sql复制代码USER nginx

VOLUME 指令用于在镜像中创建一个挂载点,用来挂载外部卷。

1
bash复制代码VOLUME /var/log/nginx

docker-compose

docker-compose 是一个用于定义和运行多容器 Docker 应用程序的工具。使用 docker-compose,你可以通过一个单独的 docker-compose.yml 文件来配置你的应用服务。这让部署多容器应用变得更加简单和便捷。

它是一个使用 yaml 文件编写的,其定义了所有相关的服务、网络和卷。在这个文件中,你可以配置多个容器和它们之间的依赖关系、共享数据卷和网络设置。每个服务可以基于 Dockerfile 构建,或者直接使用现成的 Docker 镜像。

它的核心概念和组件主要有以下几个方面:

  1. 服务(Services):在 docker-compose.yml 中,服务代表一个应用容器。实际上,每个服务都定义了运行一个镜像的配置。如果你有一个复杂的应用,比如一个前端服务器、一个后端服务器和一个数据库,每个部分都可以被定义为一个服务。
  2. 网络(Networks):Docker Compose 允许你定义和使用自己的网络,以便容器间可以轻松通信。Compose 默认为你的应用程序设置一个网络,所有配置的服务都连接到这个网络。
  3. 卷(Volumes):卷用于数据持久化和数据共享。在 docker-compose.yml 文件中定义卷可以确保数据不会随着容器的停止而丢失,并且可以在多个容器之间共享数据。

接下来我们就用 docker-compose 来编写一个简单的示例,里面包括了 MongoDB 和 minio 服务:

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
yaml复制代码version: "3.9"

services:
mongo:
image: mongo
container_name: mongodb
command: mongod --auth
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: soravideo
restart: "always"

minio:
image: minio/minio
volumes:
- data:/data
- config:/root/.minio
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: moment
MINIO_ROOT_PASSWORD: moment666
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3

volumes:
mongodata:
data:
config:

docker-compose 是一个工具,它通过一个简单的 YAML 文件来管理和部署多容器应用。这个工具使得配置、部署和扩展应用变得更加简单和一致,特别是在涉及多服务架构的开发和测试过程中。它提高了项目的可移植性和维护效率,确保了开发和生产环境的一致性。

参考资料

  • Docker 入门教程

总结

本篇文章主要在讲解一些相关的概念,一些命令和更详细的配置请移步官方文档进行查阅。

本文转载自: 掘金

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

AI Agent-知行合一 大模型的局限 AI Agent

发表于 2024-04-23

大模型的局限

大语言模型的威力我们都已经见证过,它在很多领域都展现出了惊人的实力。比如问答系统,大模型可以从海量的知识库中快速检索到相关信息并生成准确、简洁的答案。再比如文本生成,在广告文案、新闻撰写、小说创作等领域可以生成富有创意和连贯性的文本。还比如代码生成领域,开发者可以通过描述需求来生成相应的代码片段,这极大解放了一批程序员的生产力,各种copilot应运而生。

目前的大模型发展迅速,但在解决真实现实世界的问题时,毕竟仍存在它设计上或者技术实现上仍然不可调和的限制,主要包括:

  1. 缺乏自主性:LLM通常是被动地根据输入数据生成输出,而不具备主动地在环境中执行任务和做出决策的能力。这使得LLM在处理需要实时交互和自主行动的问题时可能不够高效。
  2. 缺乏长期记忆和状态:虽然LLM可以处理大量的文本数据,但由于token context的限制,它通常无法有效地存储和管理长期的记忆和状态。这可能导致在需要长期规划和决策的任务中表现不佳。
  3. 缺乏多模态处理能力:LLM主要关注文本数据的处理,而现实世界中的问题通常涉及多种类型的数据,如图像、声音等。当然现在已经有多模态的大模型出世,能够体验到端到端的多模态能力。
  4. 缺乏适应性:LLM的表现通常取决于预先训练好的模型,而在现实世界中,环境和任务可能会发生变化。
  5. 缺乏协作:LLM通常无法扮演多个角色,也无法与其他LLM或工具能力进行有效的协作。而在现实世界中,许多问题需要多个实体之间的协作来解决。

AI Agent

相较于人类本身智能的运作模式,大模型的确还在进化的初级。人类智能天生具有认知协同的特点,可以思考、整合、决策、执行。为了弥补或者增强大模型,让它不再试一个人在战斗,诞生了各种各样的研究,比较成功的即 AI Agent(本文翻译为AI智能体)

AI Agent中多个思维可以合作,结合他们的个体优势和知识,以增强复杂任务中的问题解决和整体性能。它是一种能够自主地执行任务、做出决策并在其环境中采取行动的智能系统。

AI Agent从内部机理上通用的流程上涵盖,思考、计划、行动、反思、记忆的整个链条,并且它可以吸取并影响外部环境,及和其它Agent协作。

77F97556-EC2E-46D8-85ED-C1BAAEBA4CB4.png

核心组成

人类的决策执行是一个非常复杂的过程,在AI Agent主要模拟了这个过程,这依赖于以下几个核心模块:

  1. 感知模块(Perception):感知模块负责从环境中收集信息,如图像、声音、文本等。这些信息使得AI Agent能够了解环境的状态和变化。感知模块通常包括传感器、摄像头等硬件设备,以及用于处理和分析数据的软件算法。
  2. 决策和规划模块:基于内部模型和知识表示,Agent进行推理和决策,以确定在给定环境状态下应该采取的最佳行动。这可以是通过搜索和规划算法实现,也可以是通过机器学习和优化方法实现。自我反思(Self-reflection)
  3. 记忆存储:它负责存储和管理Agent的知识、经验和信息。
  4. 行动(Action):根据推理和决策的结果,Agent执行具体的行动,以实现其目标或解决问题。行动可以是物理的(如机器人移动、抓取物体等),也可以是虚拟的(如软件Agent发送网络请求、修改数据等)。

思考规划

在AI Agent中,”plan”(计划)是指一系列有序的步骤或行动,这是AI感知、思考的产物,这些行动旨在实现Agent的特定目标或解决某个问题。计划是Agent根据其内部模型、算法和当前环境状态所生成的,用于指导其在环境中的行为。

思维链(CoT )技术已经成为这里实现的普遍标准(其它诸如思维树、LLM+P),这主要依靠的是合理的prompting工程(核心思想是通过向大语言模型说明少一些示例,并解释示例中的推理过程,大语言模型在回答时也会显示推理过程),要求LLM一步一步思考,将一个完整的问题拆分成多个子任务或者步骤,从而赋予了LLM规划的能力。例如:

2B480DDE-114F-41AD-9DB0-8D1AB7FC0433.png

才外还有一些其它的思路,比如通过在问题的结尾附加“Let’s think step by step”这几个词,大语言模型能够生成一个回答问题的思维链。

更多的记忆

在AI Agent中,记忆模块是一个关键组件,它负责存储和管理Agent的知识、经验和信息。记忆模块的设计和实现可以根据具体的任务和需求进行调整。主要围绕感觉、短期、长期记忆而来:

  1. 感觉记忆:代表原始输入,包括情景、感情、描述、图像或者其它模态。
  2. 短期记忆:短期记忆模块用于存储和管理Agent的临时信息和状态。短期记忆通常具有有限的容量和持续时间,例如工作记忆、循环神经网络(RNN)等。
  3. 长期记忆:长期记忆模块用于存储和管理Agent的持久信息和知识。长期记忆通常具有较大的容量和持续时间,例如知识库、参数化模型等。

Embedding技术和向量数据库,及各种相似度算法是实现高等记忆的基石。

64FE8AC3-A89E-434A-B0AF-60D23CBF58FE.png

工具协作

AI Agent的核心价值一方面体现在自主,另一方面最大的价值体现在可以使用外部工具拓展能力。跟人类一样能够使用工具是人类进化的一个重要标志。

Agent项目

AutoGPT

AutoGPT是一个实验性的开源应用程序,由GPT-4驱动,可以自主实现设定的任何目标。它允许用户通过命令行界面与GPT-4进行交互,并实现各种任务,如文本生成、翻译、摘要等。

6D983B55-9697-4764-A8FD-B1DC942F8051.png

BabyAGI

BabyAGI是任务驱动自治代理的精简版本。它的主要思想是基于先前任务的结果和预定义的目标来创建任务。然后,脚本使用OpenAI的语言模型功能来创建基于目标的新任务,Pinecone来存储和检索上下文的任务结果

HuggingGPT

HuggingGPT是微软开发的一个名为JARVIS的项目,它包括一个LLM作为控制器和许多专家模型作为协作执行者(来自HuggingFace Hub)。它工作流程包括四个阶段:任务规划、模型选择、任务执行和响应生成

B36C0B5B-D86A-45D5-9AF6-DEAA5DCAB55F.png

Camel

Camel是”Communicative Agents for ‘Mind’ Exploration of Large Scale Language Models”的缩写,它提出了一种新颖的代理框架,即角色扮演,作为AutoGPT和AgentGPT的替代方案。Camel将游戏和大语言模型结合,主要包含2个部分:一个支持LLM的AI代理的简单的类似rpg的环境,通过OpenAI API将AI代理植入到游戏环境的角色中;另一个是使用AI代理进行角色扮演游戏

74BB1B8E-D3D2-4B7D-AA1C-C913E55063CE.png

本文转载自: 掘金

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

【性能监控】如何有效监测网页静态资源大小?

发表于 2024-04-23

前言

作为前端人员肯定经常遇到这样的场景:需求刚上线,产品拿着手机来找你,为什么页面打开这么慢呀,心想自己开发的时候也有注意性能问题呀,不可能会这么夸张。那没办法只能排查下是哪一块影响了页面的整体性能,打开浏览器控制台一看,页面上的这些配图每张都非常大,心想这些配图都这么大,页面怎么快,那么我们有没有办法监测页面上的这些静态资源大小,从而避免这种情况的发生。

Performance

Performance 接口可以获取到当前页面中与性能相关的信息。

该对象提供许多属性及方法可以用来测量页面性能,这里介绍几个用来获取PerformanceEntry的方法:

getEntries

该方法获取一组当前页面已经加载的资源PerformanceEntry对象。接收一个可选的参数options进行过滤,options支持的属性有name,entryType,initiatorType。

1
js复制代码const entries = window.performance.getEntries();

getEntriesByName

该方法返回一个给定名称和 name 和 type 属性的PerformanceEntry对象数组,name的取值对应到资源数据中的name字段,type取值对应到资源数据中的entryType字段。

1
js复制代码const entries = window.performance.getEntriesByName(name, type);

getEntriesByType

该方法返回当前存在于给定类型的性能时间线中的对象PerformanceEntry对象数组。type取值对应到资源数据中的entryType字段。

1
js复制代码const entries = window.performance.getEntriesByType(type);

尝试获取静态资源数据

使用getEntriesByType获取指定类型的性能数据,performance entryType中有一个值为resource,用来获取文档中资源的计时信息。该类型包括有:script、link、img、css、xmlhttprequest、beacon、fetch、other等。

1
2
js复制代码const resource = performance.getEntriesByType('resource')
console.log('resource', resource)

这样可以获取到非常多关于资源加载的数据:

rs1.png
为了方便查看,我们来稍微处理下数据

1
2
3
4
5
6
7
8
9
10
11
js复制代码const resourceList = []
const resource = performance.getEntriesByType('resource')
console.log('resource', resource)
resource.forEach((item) => {
resourceList.push({
type: item.initiatorType, // 资源类型
name: item.name, // 资源名称
loadTime: `${(item.duration / 1000).toFixed(3)}s`, // 资源加载时间
size: `${(item.transferSize / 1024).toFixed(0)}kb`, // 资源大小
})
})

rs2.png

这样对于每个资源的类型、名称、加载时长以及大小,都非常清晰

但是有些资源的大小为什么会是0呢?以及还有很多页面上的资源貌似没有统计到,这是为啥呢?🤔

这是因为页面上的资源请求并不是一次性加载完的,比如一些资源的懒加载,这里就有可能会统计不到,或者资源大小统计会有问题,所以我们需要监听资源的动态加载

监听资源加载

以上介绍的3个API都无法做到对资源动态加载的监听,这里就需要用到PerformanceObserver来处理动态加载的资源了

PerformanceObserver

PerformanceObserver 主要用于监测性能度量事件,在浏览器的性能时间轴记录新的 performanceEntry 时会被通知。

通过使用 PerformanceObserver() 构造函数我们可以创建并返回一个新的 PerformanceObserver 对象,从而进行性能的监测。

用法

PerformanceObserver 与其它几个 Observer 类似,使用前需要先进行实例化,然后使用 observe 监听相应的事件

1
2
3
4
5
js复制代码function perf_observer(list, observer) {
// ...
}
var observer = new PerformanceObserver(perf_observer);
observer.observe({ entryTypes: ["resource"] });

它主要有以下实例方法:

  • observe:指定监测的 entry types的集合。当 performance entry 被记录并且是指定的 entryTypes 之一的时候,性能观察者对象的回调函数会被调用。
  • disconnect:性能监测回调停止接收PerformanceEntry。
  • takeRecords:返回当前存储在性能观察器的 performance entry列表,并将其清空。

尝试获取页面图片加载信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
js复制代码new PerformanceObserver((list) => {
list
.getEntries()
.filter(
(entry) =>
entry.initiatorType === 'img' || entry.initiatorType === 'css',
)
.forEach((entry) => {
resourceList.push({
name: entry.name, // 资源名称
loadTime: `${(entry.duration / 1000).toFixed(3)}s`, // 资源加载时间
type: entry.initiatorType, // 资源类型
size: `${(entry.transferSize / 1024).toFixed(0)}kb`, // 资源大小
})
console.log('--', resourceList)
})
}).observe({ entryTypes: ['resource'] })

这里需要注意的是,获取类型除了img还得加上css,因为CSS中可能会有通过url()加载的背景图。

rs3.png

这样,页面上的图片大小以及加载时长一目了然了

通知

我们自己是知道问题了,但是还要将这些信息推送给产品及运营,这个可以通过企业微信提供的API来进行操作,不满足条件的资源将进行推送通知:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
js复制代码setTimeout(() => {
axios.get('http://127.0.0.1:3000/jjapi/user/pushMessage', {
params: {
msgtype: 'markdown',
markdown: {
content: `
<font color="warning">H5项目资源加载异常,请注意查看</font>
类型:<font color="comment">图片资源大小超出限制</font>
异常数量:<font color="comment">${resourceList.length}例</font>
异常列表:<font color="comment">${resourceList.map(
(item) => item.name,
)}</font>`,
},
},
})
}, 8000)

通知如下:

rs4.png
这里为了避免跨域,使用nest自己包了一层,这样就能够及时发现线上配置资源是否有问题,并且这个脚本也不需要所有用户都执行,因为大家的资源都是一样的,只需要配置特定白名单(比如开发、测试、产品),在页面上线后,在进行线上回归的同时执行该脚本去监测上线配置资源是否都合理…

本文转载自: 掘金

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

轻松驾驭异步定时:Vue3的useIntervalAsync

发表于 2024-04-23

前言

我们经常会遇到需要定时执行某些操作,特别是那些需要异步处理的操作。而useIntervalAsync这个钩子函数就像是你的贴心小助手,帮助你轻松驾驭这些异步定时任务。

代码

封装成一个hooks

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
TS复制代码import { onUnmounted, ref } from 'vue';
// import { TimeUnit, toMilliseconds } from '@tmp/utils';

export type Cleanup = () => any;
export type CallbackReturn = void | Cleanup;
export type Callback = (...args: any[]) => CallbackReturn | Promise<CallbackReturn>;
// unit: TimeUnit = 'millisecond'
export const useIntervalAsync = (callback: Callback, delay: number) => {
const timeout = ref<number | null>(null);
const canceled = ref<boolean>(false);
const cleanup = ref<Cleanup | void>(); // 将延迟时间转换为毫秒

// delay = toMilliseconds(delay, unit);

const run: TimerHandler = async () => {
if (canceled.value) {
return;
} // 清理之前的回调函数
if (typeof cleanup.value === 'function') {
cleanup.value();
} // 执行回调函数并获取清理函数
cleanup.value = await Promise.resolve(callback()); // 设置下一次任务轮询的定时器
timeout.value = globalThis.setTimeout(run, delay);
}; // 初始化任务轮询

run(); // 刷新任务轮询,取消当前定时器,重新执行回调函数

const flush = () => {
// eslint-disable-next-line no-unused-expressions
timeout.value && globalThis.clearTimeout(timeout.value);
run();
}; // 取消任务轮询,清理定时器和回调函数

const cancel = () => {
// eslint-disable-next-line no-unused-expressions
timeout.value && globalThis.clearTimeout(timeout.value);
canceled.value = true;
if (typeof cleanup.value === 'function') {
cleanup.value();
}
}; // 恢复任务轮询,重新启动定时器

const recover = () => {
canceled.value = false;
flush();
}; // 在组件卸载时取消任务轮询

onUnmounted(() => {
cancel();
});

return {
flush,
cancel,
recover,
};
};

export default useIntervalAsync;

解析

一、了解useIntervalAsync*\

useIntervalAsync接收两个参数:一个回调函数和一个延迟时间。回调函数是你希望在每次定时器触发时执行的异步操作,而延迟时间则决定了这个操作执行的频率。

二、内部工作原理

  1. 引用变量:useIntervalAsync内部使用了Vue 3的ref函数来创建响应式引用,包括timeout(存储定时器的ID)、canceled(表示定时器是否已取消)和cleanup(存储清理函数)。
  2. 定时逻辑:在run函数中,首先检查canceled的值,如果为true,则直接返回,不再执行后续操作。然后,如果cleanup中有函数,则执行它进行清理工作。接着,执行回调函数并等待其完成(或等待其返回的Promise完成),将返回的清理函数(如果有的话)赋值给cleanup,并设置下一次定时器的触发时间。
  3. 初始化和刷新:在钩子函数被调用时,会立即执行一次run函数来初始化定时器。而flush函数则用于刷新定时器,它会先清除当前的定时器(如果存在),然后重新执行run函数来设置新的定时器。
  4. 取消和恢复:cancel函数用于取消定时器,并将canceled设置为true,同时执行cleanup中的清理函数(如果有的话)。而recover函数则用于恢复定时器,它将canceled设置为false,并调用flush函数来刷新定时器。
  5. 组件卸载时的清理:通过onUnmounted钩子,确保在组件卸载时调用cancel函数来取消定时器,避免内存泄漏。

三、使用与优势

使用useIntervalAsync非常简单,只需传入回调函数和延迟时间即可。而且,由于它支持异步操作和清理函数,你可以更加灵活和高效地管理你的代码。无论是在数据获取、状态更新还是其他需要定时执行的异步操作中,useIntervalAsync都能帮助你轻松应对。

想象一下,useIntervalAsync就像一个训练有素的管家。你告诉它什么时候该做什么(回调函数和延迟时间),它就会准时为你完成任务。而且,它还很聪明,知道什么时候该出现(定时器触发时),什么时候该隐身(定时器取消时)。最重要的是,它还会帮你处理那些繁琐的清理工作,让你的代码保持整洁和高效

应用

场景:扫码登录

如我之前写的这篇文章:业务: 前后端实现二维码扫码登录-深度剖析

扫码登录应用场景与useIntervalAsync的结合

扫码登录是许多应用(如微信、支付宝、QQ等)中常见的一种登录方式。用户通过扫描屏幕上的二维码,快速、便捷地完成登录过程。而useIntervalAsync这个钩子函数,在这种场景下也可以发挥重要的作用。

扫码登录流程

  1. 生成二维码:用户在前端页面上触发扫码登录操作,后端生成一个唯一的二维码并返回给前端展示。
  2. 轮询检查登录状态:用户扫描二维码后,后端会验证二维码并等待用户的确认登录操作。此时,前端需要通过某种方式轮询检查登录状态。传统的轮询方式可能会使用setInterval,但这种方式不支持异步操作,并且不能很好地处理异步任务。而useIntervalAsync则完美解决了这个问题。

useIntervalAsync在扫码登录中的应用

  • 异步检查登录状态:在useIntervalAsync的回调函数中,你可以发起一个异步请求到后端,检查用户是否已经扫描二维码并确认登录。这个异步请求可能是基于fetch、axios等库。
  • 处理登录状态变化:一旦后端返回用户已经确认登录的信息,你可以在回调函数中处理这个状态变化,比如跳转到用户的主页或者显示登录成功的提示。
  • 清理定时器:当用户成功登录后,你需要停止轮询操作。在useIntervalAsync中,你可以通过调用cancel函数来清除定时器,确保不再发起不必要的请求。
  • 优雅的错误处理:如果在轮询过程中发生错误,比如网络问题或者后端服务不可用,你可以在useIntervalAsync的回调函数中捕获这些错误,并给用户展示相应的提示信息

扫码登录应用场景与useIntervalAsync的结合

扫码登录是许多应用(如微信、支付宝、QQ等)中常见的一种登录方式。用户通过扫描屏幕上的二维码,快速、便捷地完成登录过程。而useIntervalAsync这个钩子函数,在这种场景下也可以发挥重要的作用。

扫码登录流程

useIntervalAsync在扫码登录中的应用

  • 异步检查登录状态:在useIntervalAsync的回调函数中,你可以发起一个异步请求到后端,检查用户是否已经扫描二维码并确认登录。这个异步请求可能是基于fetch、axios等库。
  • 处理登录状态变化:一旦后端返回用户已经确认登录的信息,你可以在回调函数中处理这个状态变化,比如跳转到用户的主页或者显示登录成功的提示。
  • 清理定时器:当用户成功登录后,你需要停止轮询操作。在useIntervalAsync中,你可以通过调用cancel函数来清除定时器,确保不再发起不必要的请求。
  • 优雅的错误处理:如果在轮询过程中发生错误,比如网络问题或者后端服务不可用,你可以在useIntervalAsync的回调函数中捕获这些错误,并给用户展示相应的提示信息
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
ts复制代码import { ref, onUnmounted } from 'vue';
import useIntervalAsync from './useIntervalAsync'; // 假设useIntervalAsync已经定义好并可以导入

export default {
setup() {
const checking = ref(true); // 控制是否还在轮询检查状态
const isLoggedIn = ref(false); // 存储用户是否已登录的状态
const loginStatusError = ref(null); // 存储登录状态检查过程中出现的错误

// 模拟扫码登录的异步检查函数
const checkLoginStatus = async () => {
try {
// 发起异步请求到后端检查登录状态
const response = await fetch('/api/check-login-status');
const data = await response.json();

if (data.loggedIn) {
// 如果已登录,设置状态并停止轮询
isLoggedIn.value = true;
checking.value = false;
// 这里可以添加登录成功后的处理逻辑,如跳转到用户主页
} else {
// 如果未登录,继续轮询
checking.value = true;
}
} catch (error) {
// 处理请求错误
loginStatusError.value = error;
// 根据业务需要,可以选择是否停止轮询
checking.value = false;
}
};

// 使用useIntervalAsync设置轮询
const { cancel, flush } = useIntervalAsync(checkLoginStatus, 2000); // 每2秒检查一次登录状态

// 组件卸载时取消轮询
onUnmounted(() => {
cancel();
});

// 初始化轮询
if (checking.value) {
flush();
}

return {
isLoggedIn,
loginStatusError,
// 可以将cancel和flush暴露给模板,以便在需要时手动控制轮询
cancel,
flush
};
}
};

image.png

gh_db79ec2f6f73_258.jpg

在这个例子中,checkLoginStatus函数是一个异步函数,它模拟了向后端发起请求检查用户是否已经扫码并确认登录的过程。这个函数被useIntervalAsync定时调用,每隔2秒执行一次。

当isLoggedIn变为true时,表示用户已经成功登录,此时轮询将停止。如果在轮询过程中发生错误,loginStatusError将被设置,并且可以根据业务逻辑选择是否停止轮询。

在Vue组件的setup函数中,我们通过onUnmounted钩子确保在组件卸载时取消轮询,以避免不必要的资源消耗和潜在的错误。

最后,我们将isLoggedIn、loginStatusError、cancel和flush暴露给模板,这样我们就可以在模板中显示登录状态,或者在需要时手动控制轮询的开始和结束。

本文转载自: 掘金

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

使用 Swift 递归搜索目录中文件的内容,同时支持 Glo

发表于 2024-04-23

前言

如果你新加入一个团队,想要快速的了解团队的领域和团队中拥有的代码库的详细信息。

如果新团队中的代码库在 GitHub / GitLab 中并且你不熟悉代码所有权模型的概念或格式。本篇文章以 GitHub 为例,你可以使用 Glob 模式将一个或多个文件链接到 GitHub 团队。

如果新团队中的代码库有一个 GitHub 的 CODEOWNERS 文件,可以反映拥有的每个文件或文件组。这是对了解整个框架有很大帮助,如果没有,可以尝试创建一个。如下:

  • /Tests/ @MyAwesomeOrg/cool-beans
  • /Modules/Account/Tests/* @MyAwesomeOrg/cool-beans
  • /Modules/Account/Settings/**/Views @MyAwesomeOrg/cool-beans

我曾经经历手动去查找团队拥有的文件中的文本出现的次数,比如固定模块的多次重复使用,这非常的耗费时间。

本篇文章讲帮助大家写一个小脚本来自动完成这项任务,给定一些文本片段和一个 GitHub 团队标签,它将在团队拥有的文件中找到该文本的所有出现次数。

项目设置

首先,要做的第一件事是创建一个可执行的 Swift Package:

1
2
mkdir find-code-owner && cd find-code-owner
swift package init --name FindCodeOwner --type executable

然后,将 ChimeHQ 的 GlobPattern Swift Package 添加为依赖项,以帮助确定包含查询文本的文件是否由提供的 GitHub 团队拥有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// swift-tools-version: 5.10

import PackageDescription

let package = Package(
name: "FindCodeOwner",
platforms: [
.macOS(.v13)
],
dependencies: [
.package(url: "https://github.com/ChimeHQ/GlobPattern.git", exact: "0.1.1")
],
targets: [
.executableTarget(
name: "FindCodeOwner",
dependencies: ["GlobPattern"],
swiftSettings: [
.enableUpcomingFeature("BareSlashRegexLiterals")
]
),
]
)

查找文件

假设我们的团队想要迁移一个名为 Quick 的依赖,我们想要找到所有我们拥有的导入该库的文件。

让我们在我们的可执行目标中编写一些代码来实现这一点:

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
import Foundation
import GlobPattern

struct OwnershipRule {
let path: String
let teams: [String]
}

func getRules(from codeOwnersFile: String, relativeTo repository: String) -> [OwnershipRule] {
guard let content = try? String(contentsOfFile: codeOwnersFile) else {
return []
}

return content
.components(separatedBy: .newlines)
.filter { $0.isEmpty || $0.hasPrefix("#") }
.map { createRule(from: $0, relativeTo: repository) }
}

func createRule(from line: String, relativeTo repository: String) -> OwnershipRule {
let elements = line.components(separatedBy: .whitespaces)
.filter { !$0.isEmpty }

let teams = elements
.enumerated()
.filter { $0 != 0 && $1.hasPrefix("@") }
.map(\.1)

return OwnershipRule(path: repository + elements[0], teams: teams)
}

func getOwnersForFile(_ filePath: String, rules: [OwnershipRule]) -> [String] {
rules
.reversed()
.first { rule in
let globExpression = URL(string: rule.path)?.hasDirectoryPath == true ? rule.path + "*" : rule.path
let matcher = try? Glob.Pattern(globExpression)
return matcher?.match(filePath) == true
}?
.teams ?? []
}

// 1
let rootRepositoryDirectory = FileManager.default.currentDirectoryPath
let codeOwnersPath = rootRepositoryDirectory + "/.github/CODEOWNERS"

// 2
let allOwnershipRules = getRules(from: codeOwnersPath, relativeTo: rootRepositoryDirectory)

// 3
let matchingSearch = "import Quick"
let dirEnum = FileManager.default.enumerator(atPath: rootRepositoryDirectory)
var matchedFiles = [String]()
while let file = dirEnum?.nextObject() as? String {
guard file.hasSuffix(".swift") else { continue }

let fullPath = rootRepositoryDirectory + "/" + file
if let contents = FileManager.default.contents(atPath: fullPath),
let stringContents = String(data: contents, encoding: .utf8),
stringContents.contains(matchingSearch) {

matchedFiles.append(fullPath)
}
}

// 4
let matchedFilesOnwedByTeam = matchedFiles
.filter { fileContainingSearchQuery in
getOwnersForFile(fileContainingSearchQuery, rules: allOwnershipRules).contains("@MyAwesomeOrg/cool-beans")
}

// 5
print(matchedFilesOnwedByTeam)

上面这段代码的主要目的是从代码库中查找特定团队拥有的文件,并筛选出其中包含指定文本的文件。让我们逐步解释代码的意义、作用和可扩展性。

读取CODEOWNERS文件

通过 getRules(from: codeOwnersPath, relativeTo: rootRepositoryDirectory) 函数从 CODEOWNERS 文件中获取规则。
这些规则定义了哪些文件或目录由特定团队拥有。

解析规则

getRules(from: codeOwnersPath, relativeTo: rootRepositoryDirectory) 函数解析 CODEOWNERS 文件的内容,生成 OwnershipRule 结构体的数组。

每个 OwnershipRule 结构体包含文件路径和相应的团队。

搜索匹配的文件

脚本使用 FileManager 遍历当前代码库中的所有 .swift 文件。

对于每个文件,检查是否包含了匹配的文本(例如,import Quick)。

确定文件所有者

对于包含匹配文本的文件,使用 getOwnersForFile(_:_:) 函数确定其所有者。

getOwnersForFile(_:_:) 函数根据文件路径和规则数组,确定文件的拥有者团队。

输出结果

将文件所有者为 @MyAwesomeOrg/cool-beans 的匹配文件打印输出。

通过这段脚本可以帮助开发者快速找到特定团队拥有的文件,并检查其中是否包含特定的文本。它的可扩展性取决于 CODEOWNERS 文件的格式和内容,以及要搜索的文本类型。例如,可以扩展代码以支持更多类型的文本搜索,或者为不同的团队提供不同的匹配逻辑。此外,可以根据需要添加更多的文件过滤规则或其他自定义逻辑。

总结

最后我想到了一些更加实用的功能,抽时间给大家分享。在未来,可以考虑添加更多的文件过滤规则或支持其他类型的文本搜索,以增强功能。例如,可以添加对不同文件类型的支持,或者实现更复杂的团队匹配逻辑。另外,还可以考虑添加用户界面和更友好的输出方式,以提升用户体验。

本文转载自: 掘金

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

iOS UITextView 加载 HTML 时的问题与优化

发表于 2024-04-23

在 iOS 中如果想加载显示 HTML 文本,一般有以下的几种方案:

  1. 使用 WKWebView ,偏重、性能较差
  2. 将 HTML 字符串转换为 NSAttributedString 对象,使用 UITextView,UILabel…
  3. 使用一些三方库,如 DTCoreText、SwiftSoup
  4. 自己去解析标签实现,较复杂。

对于一些详情原生页面,加载一段功能简单的 html 标签文本,使用 NSAttributedString + UITextView 是一种相对轻量的选择,本文也只讨论这种方式。

然而在实际开发的过程中,我们很容易发现一些问题:

1、html 字符串转 NSAttributedString 是同步的,文本稍大一点,就会阻塞主线程,页面卡死。

这个问题好解决,直接将转换操作放到子线程去做就好

1
2
3
4
5
6
objc复制代码dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSAttributedString *att = [htmlString htmlToAttr];
dispatch_async(dispatch_get_main_queue(), ^{
self.textView.attributedText = att;
});
});
2、如果只是一些片段 html 标签,转换后的样式可能不太美观,可以加一些 CSS 来美化。

比如字体默认太小,图片显示太宽等。这时我们可以自己拼接一些 CSS 进去,下面代码我们增加默认字体大小 16px,图片宽度为 textView 宽度,高度自适应。

1
2
xml复制代码CGFloat contentWidth = self.textView.bounds.size.width;
NSString *newHtml = [NSString stringWithFormat:@"<head><style>body%@img{width:%f !important;height:auto}</head></style>%@",@"{font-size:16px;}",contentWidth,html];

你也可以去遍历 NSParagraphStyleAttributeName 属性,来设置一些 style。

1
2
3
4
5
6
7
8
9
10
11
objc复制代码// 设置行高
- (NSAttributedString*)addLineHeight:(CGFloat)lineHeight attr:(NSAttributedString*)attr {
[attr enumerateAttribute:NSParagraphStyleAttributeName inRange:NSMakeRange(0, attr.length) options:(NSAttributedStringEnumerationLongestEffectiveRangeNotRequired) usingBlock:^(NSMutableParagraphStyle *style, NSRange range, BOOL * _Nonnull stop) {
NSAttributedString *att = [attr attributedSubstringFromRange:range];
// 忽略 table 标签
if (![[att description] containsString:@"NSTextTableBlock"]) {
style.lineSpacing = lineHeight;
}
}];
return attr;
}
3、加载图片过大、多图时,首次显示很慢,拼网络了。

这种时候,我们可以先使用正则找出所有的 <img> ,然后有两个种方案选择:

1、将所有 <img> 删除, 先显示无图片的文本内容,再去加载原始带图片的 html。

1
2
3
objc复制代码NSString *pattern = @"<img[^>]*>";
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil];
NSString *resultString = [regex stringByReplacingMatchesInString:html options:0 range:NSMakeRange(0, html.length) withTemplate:@""];

2、将所有 <img> 替换为本地的默认图片,先显示带默认图片的,再去加载原始的 html。

1
2
3
4
5
6
7
objc复制代码 // 使用占位图
NSString *fileUrl = [[NSBundle mainBundle] URLForResource:@"default_cover" withExtension:@"png"].absoluteString;
NSString *replacement =[NSString stringWithFormat:@"<img src=\"%@\">", fileUrl];

NSString *pattern = @"<\\s*img\\s+[^>]*?src\\s*=\\s*[\'\"](.*?)[\'\"]\\s*(alt=[\'\"](.*?)[\'\"])?[^>]*?\\/?\\s*>";
NSRegularExpression *regexImg = [NSRegularExpression regularExpressionWithPattern:pattern options:NSRegularExpressionCaseInsensitive error:nil];
NSString *resultString = [regexImg stringByReplacingMatchesInString:html options:0 range:NSMakeRange(0, html.length) withTemplate:replacement];

这样,我们能减少首次显示的时间。

4、实现图片点击,能查看大图

在设置 UITextViewDelegate 代理后,通过代理方法去拦截。

1
2
3
4
5
objc复制代码self.textView.delegate = self;

- (BOOL)textView:(UITextView *)textView shouldInteractWithTextAttachment:(NSTextAttachment *)textAttachment inRange:(NSRange)characterRange interaction:(UITextItemInteraction)interaction {
// 拦截到了点击,但是获取不到点击的图片
}

虽然能拦截到图片点击了,但是拿不到图片的 url,以及点击的是第几张图片。不过,如果加载的图片来自 fileURL,那就能拿到文件名 NSString *fileName = textAttachment.fileWrapper.filename;

我们完全可以实现一套 HTML 里的图片缓存,使用正则匹配出所有 <img src=> 获取到图片 url , 借助 SDWebImage 去下载图片保存到本地,再用这个图片 fileURL 去替换掉原 src的内容。即达到了使用自己缓存的目的,这样加载出来的图片,点击时,可以知道点击的图片名 filename。

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
objc复制代码// 仅部分代码,完整的请看 demo: https://github.com/iHongRen/UITextView-html-demo

// 找到所有图片url,imgs
NSMutableArray *imgs = [NSMutableArray array];
for (NSTextCheckingResult *match in matches) {
NSRange matchRange = [match rangeAtIndex:1];
NSString *imageUrl = [html substringWithRange:matchRange];
[imgs addObject:imageUrl];
}

// 下载完成后进行 url 替换
__block NSString *newHtml = html;
for (NSInteger i=0; i<imgs.count; i++) {
NSString *imageUrl = imgs[i];

[imageUrl downloadImageIfNeeded:^(NSURL *URL) {
if (URL) {
NSArray *matches = [regexImg matchesInString:newHtml options:0 range:NSMakeRange(0, newHtml.length)];
NSRange matchRange = [matches[i] rangeAtIndex:1];
newHtml = [newHtml stringByReplacingOccurrencesOfString:imageUrl withString:URL.absoluteString options:NSCaseInsensitiveSearch range:matchRange];
}
}];
}

// 下载图片,保存本地
- (void)downloadImageIfNeeded:(void(^)(NSURL *fileURL))block {
NSString *key = [self storeKeyForUrl];
[[SDWebImageDownloader sharedDownloader] downloadImageWithURL:[NSURL URLWithString:self] completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, BOOL finished) {
if (data) {
[[SDImageCache sharedImageCache] storeImageDataToDisk:data forKey:key];
}
NSURL *URL = [key fileURLForImageKey];
if (block) {
block(URL);
}
}];
}
}

拿到 filename 之后,我们就可以匹配出点击的图片 url 以及 索引位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
objc复制代码NSString *fileName = textAttachment.fileWrapper.filename;
if (!fileName) {
return YES;
}

// self.imgUrls 是我们匹配出的所有图片url
for (NSInteger i=0; i<self.imgUrls.count; i++) {
NSString *imgUrl = self.imgUrls[i];
NSString *key = [imgUrl storeKeyForUrl];
NSString *path = [[SDImageCache sharedImageCache] cachePathForKey:key];
if ([path containsString:fileName]) {
[self showToast:[NSString stringWithFormat:@"你点击了第%@张图片\n%@",@(i),imgUrl]];
break;
}
}
5、上面的方法还需要注意,如果图片的 src 是 base64 url,需要特殊处理

将 base64 字符串直接转为图片,再存储到本地

1
2
3
4
5
6
7
8
9
10
11
12
objc复制代码// NSString+Html.m 类别

- (BOOL)isBase64Url {
return [self hasPrefix:@"data:image/"];
}

NSString *base64 = [self componentsSeparatedByString:@"base64,"].lastObject;
NSData *data = [[NSData alloc] initWithBase64EncodedString:base64 options:NSDataBase64DecodingIgnoreUnknownCharacters];
if (data) {
[[SDImageCache sharedImageCache] storeImageDataToDisk:data forKey:key];
}
NSURL *URL = [key fileURLForImageKey];
6、还有一种较为简单的方法能获取点击的图片

通过遍历所有 NSAttachmentAttributeName 与点击的 textAttachment 对比,从而找到对应点击的图片索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
objc复制代码- (BOOL)textView:(UITextView *)textView shouldInteractWithTextAttachment:(NSTextAttachment *)textAttachment inRange:(NSRange)characterRange interaction:(UITextItemInteraction)interaction {

   __block NSInteger index = 0;
  [self.textView.attributedText enumerateAttribute:NSAttachmentAttributeName inRange:NSMakeRange(0, self.textView.attributedText.length) options:(NSAttributedStringEnumerationLongestEffectiveRangeNotRequired) usingBlock:^(NSTextAttachment *attachment, NSRange range, BOOL * _Nonnull stop) {
       
       if (attachment) {

           if (attachment==textAttachment) {
               *stop = YES;
          } else {
               index++;
          }
      }
  }];
   
   // self.imgUrls 是我们匹配出的所有图片url
   if (index < self.imgUrls.count) {
      [self showToast:[NSString stringWithFormat:@"你点击了第%@张图片\n%@",@(index),self.imgUrls[index]]];
  }

 return YES;
}
7、如果你的 html 比较复杂,在一些机型上可能会遇到加载卡死、崩溃。

你可以关闭一些属性,这些属性会增加布局复杂性和计算成本,导致渲染卡死

1
2
3
4
5
objc复制代码// NSLayoutManager 不会考虑字体领先间距,行高将仅包括字体的实际高度,而不会有额外的垂直间距
self.textView.layoutManager.usesFontLeading = NO;

// 用于指定文本容器(text container)是否允许非连续布局。
self.textView.layoutManager.allowsNonContiguousLayout = NO;
8、如果使用 textView 的宽度去计算富文本高度,再把这个高度赋予 textView 时,内容显示不完整。

这是因为 textView 有默认自带的边距,导致计算用的宽度和显示的宽度不一致。

1
2
3
4
5
6
7
8
objc复制代码// 取消默认的边距
self.textView.textContainer.lineFragmentPadding = 0;
self.textView.textContainerInset = UIEdgeInsetsZero;

- (CGFloat)heightForAttr:(NSAttributedString *)attr width:(CGFloat)width {
   CGSize contextSize = [attr boundingRectWithSize:(CGSize){width, CGFLOAT_MAX} options:NSStringDrawingUsesLineFragmentOrigin context:nil].size;
   return contextSize.height;
}
9、如果你想保留 textView 可交互,又要禁止它的长按弹出菜单(拷贝,选择,…)
1
2
3
4
5
6
objc复制代码self.textView.editable = YES;

#pragma mark - UITextViewDelegate
- (BOOL)textViewShouldBeginEditing:(UITextView *)textView {
   return NO;
}
10、默认链接是用外部浏览器打开的,如果你想用 App 内 webView 打开,可以拦截 url 的交互
1
2
3
4
5
objc复制代码- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange interaction:(UITextItemInteraction)interaction {
   // 点击 URL 交互拦截
   NSLog(@"URL:%@",URL);
   return YES;
}

如果本文对您有帮助,请给个小赞👍🏻。

demo 地址:github.com/iHongRen/UI…

demo 中有耗时显示,但不同机型测试结果不一。

本文转载自: 掘金

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

一个小小的需求,竟让我不得不写一个react-simple-

发表于 2024-04-23

需求背景

事情是这样的,后端返回类似这样的数据格式 cshjdkvghsv<xxx xx='xxx' />sdhjkshfv'<xxx xx='xxx'></xxx>'sdhgjdsk,然后需要前端将字符串中的xxx解析出来并渲染到页面中,还要保证渲染顺序,其中xxx可能是一个自定义的react组件名,也有可能是一个html元素标签名,而xx则可能是属性名,’xxx’则是属性值,比如className=’base-1’。比如xxx是div元素,我们最终的页面渲染结果就应该是。

1
html复制代码cshjdkvghsv<div className='test-1'></div>sdhjkshfv'<div className='test-2'></div>'sdhgjdsk

如果只是单纯的html元素,其实我们只需要使用dangerousSetInnerHTML属性即可,可是这里还有自定义组件元素呀。

最终的结果就是,我们需要对模板字符串进行解析,解析成一个数组,方便渲染,最终我们的react组件使用如下:

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
tsx复制代码import React, { Fragment, createElement } from 'react';
import simpleJSXParser from 'jsx-parser';
// 导入需要渲染的组件
import xxx from 'xxx';
// 定义组件对象
const components = {
CustomComponent: xxx,
}
const RenderStr = (props: { str?: string }) => {
const { str = '' } = props;
// 可以考虑写一个自定义的过滤props方法,对props进行更详细的规范化
const filterProps = (data: Record<string, string>, index: number) => {
const { xxx = '', ...rest } = data || {};
return {
xxx: xxx,
...rest,
key: `item-${xxx}-${index}`,
className: 'test-1',
};
};
// CustomComponent为自己想要渲染的组件
return (
<>
{simpleJSXParser(str).map((item, index) => (
<Fragment key={`item-${index}`}>
{typeof item === 'string' ? item : createElement(components[item.type], filterProps(item.props, index), null)}
</Fragment>
))}
</>
);
};

然后就可以得到我们实际想要渲染的效果,那么现在的问题就是,如何将模板字符串渲染成一个顺序正常的数组。

即实现如下效果:

1
2
js复制代码const str = '111<CustomComponent value="123">222';
const parser = simpleJSXParser(str); // ['111',{ type:'CustomComponent', props:{ value:'123' }},'222']

这里,我们就需要用到正则表达式,当然由于正则表达式的实现不同,可能会存在限制。

实现思路

我们实现这个解析器的思路很简单,我们会用一个结果数组来存储最终的结果,这个数组的数组项可能是字符串,也有可能是react组件对象,即{type: string,props:Record<string,string>},因此我们是选需要定义一个类型,如下所示:

1
ts复制代码type ReactComponentObj = { type: string; props: Record<string, string> };

ps:其实严格来说props的属性值不一定是字符串,不过这里我们是根据需求来扩展的,我们先实现一个基础版本。

为了提取出props和组件元素名,我们需要定义3个正则表达式,如下所示:

1
2
3
4
5
6
ts复制代码// 匹配组件元素
const componentRegExp = /(<\w+(([\s\S])*?)<\/\w+>)/;
// 匹配props
const componentPropRegExp = /(\w+)=["|']?(.+?)["|']/g;
// 匹配标签元素
const tagRegExp = /(<.+?>)?<\/?.+?>/;

以上正则表达式也有可能存在问题,不过还是可以满足当下的需求。

首先我们还是需要定义一个函数以及一些变量,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
ts复制代码const simpleJSXParser = (template: string) => {
const componentRegExp = /(<\w+(([\s\S])*?)<\/\w+>)/;
const componentPropRegExp = /(\w+)=["|']?(.+?)["|']/g;
const tagRegExp = /(<.+?>)?<\/?.+?>/;
const len = template.length; // 字符串长度
const res: (string | ReactComponentObj)[] = []; // 结果数组
if(len > 0){
// ...
}
// 返回结果
return res;
};

接下来就是核心逻辑,我们可以如此实现,我们需要定义2个变量,一个用来拼接普通内容字符串,一个用来拼接匹配到的元素字符串,然后我们根据字符长度去循环,每次匹配到一个拼接的字符串,就将总字符串长度减去拼接的字符串的长度,这也是结束循环所必须要做的。代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
ts复制代码const simpleJSXParser = (template: string) => {
// ...
if(len > 0){
let i = len,
resStr = '',
componentRes = '';
while(i > 0){
// ...
}
}
// 返回结果
return res;
};

如果匹配到组件元素字符串,我们则需要单独进行处理,因此我们实现一个createComponent方法,该方法的参数就是组件元素字符串,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ts复制代码const simpleJSXParser = (template: string) => {
// ...
if(len > 0){
let i = len,
resStr = '',
componentRes = '';
const createComponent = (v: string): ReactComponentObj => {
// ...
}
while(i > 0){
// ...
}
}
// 返回结果
return res;
};

可以看到,这个方法我们会返回一个组件对象,这将在后面介绍实现原理,这里我们先跳过,接下来我们来看循环里面的逻辑实现。

在循环里面,我们会先判断是否匹配到组件元素,如果没有,则整个模板字符串就是普通的字符串内容,直接添加到结果数组中即可。

如果含有组件元素,则获取匹配到的组件元素的起始索引值,通过字符串的match方法即可,当match方法匹配到对应的字符串,则会有对应的index属性,这就是匹配的索引值。

然后我们从0开始到起始索引值为止,拼接每一个字符,因为组件元素字符串前面可能会存在普通内容字符串,因此我们需要循环拼接这个字符串。

拼接完成之后,我们要添加到结果数组当中,然后从模板字符串中剔除掉resStr字符,使用replace方法匹配resStr即可,并修改外层循环i的索引值,即i -= resStr.length,修改完成之后,我们还要重置该结果值,即resStr = ''。

根据如上分析,我们可以写出如下代码:

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
ts复制代码const simpleJSXParser = (template: string) => {
// ...
if(len > 0){
// ...
while(i > 0){
const match = template.match(componentRegExp);
// 如果匹配到组件元素
if(match){
// 获取起始索引值
const start = match?.index;
// 循环
for(let i = 0;i < start!;i++){
// 此时只是拼接组件元素字符串前面的字符串,这个字符串应该是一个普通内容字符串
resStr += template[i];
}
// 将拼接好的字符串添加到结果数组中
res.push(resStr);
// 循环值变更
i -= resStr.length;
// 修改模板字符串,剔除符合条件的resStr
template = template.replace(resStr,'');
// 重置resStr变量
resStr = '';
}else{
// 没有则是一个普通字符串,直接添加,并重置索引值为0
i = 0;
res.push(template);
}
}
}
// 返回结果
return res;
};

接下来剩余字符串还会有组件元素和普通字符串的情况,我们继续匹配组件元素,按照同样的思路,拼接组件元素字符串到另一个变量,并添加到结果数组中,注意这个时候,我们就需要调用createComponent方法,将组件元素字符串转换成对应的组件数据对象。如下所示:

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
ts复制代码const simpleJSXParser = (template: string) => {
// ...
if(len > 0){
// ...
while(i > 0){
const match = template.match(componentRegExp);
// 如果匹配到组件元素
if(match){
//...
// 继续匹配标签元素,事实上我们定义的第一个正则表达式也可以
const matchComponent = template.match(tagRegExp);
if(matchComponent){
// 匹配到结果就是最终的组件元素字符串,赋值给另一个变量
componentRes = matchComponent[0];
// 从模板字符串中剔除组件元素字符串
template = template.replace(componentRes,'');
// 循环索引值递减
i -= componentRes.length;
// 添加结果
res.push(createComponent(componentRes));
// 清空变量
componentRes = '';
}else{
// 整个剩余字符就是普通字符串,直接添加即可
res.push(template);
// 索引值递减
i -= template.length;
}
}else{
//...
}
}
}
// 返回结果
return res;
};

接下来,我们就来看createComponent的实现,这个函数的作用其实就是从中找出组件名,以及相应的属性,然后返回即可。同样我们还是利用正则表达式,这里也涉及到了一个正则表达式捕获组的概念。

ps: 关于正则表达式捕获组的概念可以参考这篇文章。

通常我们的组件元素都是英文字母,因此我们可以通过/\w/来匹配组件名,而属性名和属性值,我们则可以通过componentPropRegExp来匹配,可以发现我们创建了2个捕获组,第一个捕获组和第二个捕获组分别就是我们想要的属性名和属性值,因此我们只需要提取属性名和属性值组合成对象即可。

根据以上分析,我们就可以写出如下代码:

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
ts复制代码const simpleJSXParser = (template: string) => {
// ...
if(len > 0){
let i = len,
resStr = '',
componentRes = '';
const createComponent = (v: string): ReactComponentObj => {
// 匹配组件名
const componentName = v.match(/\w+/)?.[0] as string;
// 匹配属性名和属性值
const props = v.match(componentPropRegExp)?.map(item => ({
// 第一个捕获组即属性名,第二个捕获组即属性值
key: item.replace(componentPropRegExp,(_,_1) => _1),
value: item.replace(componentPropRegExp,(_,_1,_2) => _2)
})).reduce((res,item) => {
// 构造成props对象,并返回
res[item.key] = item.value;
return res;
},{} as Record<string, string>)
return {
type: componentName,
props
}
}
while(i > 0){
// ...
}
}
// 返回结果
return res;
};

将以上代码整合起来就得到了我们最终解析器的代码,如下所示:

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
ts复制代码type ReactComponentObj = { type: string; props: Record<string, string> };
export const simpleJSXParser = (template: string) => {
const componentRegExp = /(<\w+(([\s\S])*?)<\/\w+>)/;
const componentPropRegExp = /(\w+)=["|']?(.+?)["|']/g;
const tagRegExp = /(<.+?>)?<\/?.+?>/;
const len = template.length;
const res: (string | ReactComponentObj)[] = [];
if (len > 0) {
let i = len,
resStr = '',
componentRes = '';
const createComponent = (v: string): ReactComponentObj => {
const componentName = v.match(/\w+/)?.[0] as string;
const props =
v
.match(componentPropRegExp)
?.map(item => {
return {
key: item.replace(componentPropRegExp, (_, _1) => _1),
value: item.replace(componentPropRegExp, (_, _1, _2) => _2),
};
})
.reduce((res, item) => {
res[item.key] = item.value;
return res;
}, {} as Record<string, string>) || {};
return {
type: componentName,
props,
};
};
while (i > 0) {
const match = template.match(componentRegExp);
if (match) {
const start = match?.index;
for (let i = 0; i < start!; i++) {
resStr += template[i];
}
res.push(resStr);
i -= resStr.length;
template = template.replace(resStr, '');
resStr = '';
const matchComponent = template.match(tagRegExp);
if (matchComponent) {
componentRes = matchComponent[0];
template = template.replace(componentRes, '');
i -= componentRes.length;
res.push(createComponent(componentRes));
componentRes = '';
} else {
res.push(template);
i -= template.length;
}
} else {
i = 0;
res.push(template);
}
}
}
// console.log(111, res);
return res;
};

当然,以上代码还存在不少问题,首先组件元素名我们也会有存在"-"的情况,这里我们并没有考虑进去,其次匹配标签的时候,这里的正则表达式是忽略掉了单闭合标签的,只能匹配成对的标签,这也是后续需要考虑优化的事情。

接下来,我们来使用一下这个解析器。

应用

使用vite初始化一个react-ts项目,然后新建一个utils目录,创建data.ts,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ts复制代码import { createUUID } from "./uuid";

export const renderList = [
{
key: createUUID(),
value: `这是一个自定义的输入框组件:<CustomInput placeholder="请输入内容" className="base-input"/></CustomInput>这是一个自定义的输入框组件:<CustomInput placeholder="请输入内容" className="base-input" value="123"/></CustomInput>`
},
{
key: createUUID(),
value: `这是一个自定义的标签组件:<CustomTag className="base-tag" color="#2396ef"/ value="标签1"></CustomTag>这是一个自定义的标签组件:<CustomTag className="base-tag" color="#2396ef" value="标签2"/></CustomTag>`
},
{
key: createUUID(),
value: `这是一个自定义的输入框组件:<CustomInput placeholder="请输入内容" className="base-input" value="123"/></CustomInput>这是一个自定义的输入框组件:<CustomInput placeholder="请输入内容" className="base-input" value="123"/></CustomInput>`
},
{
key: createUUID(),
value: `这是一个自定义的标签组件:<CustomTag className="base-tag" color="#2396ef" value="标签1"/></CustomTag>这是一个自定义的标签组件:<CustomTag className="base-tag" color="#2396ef" value="标签2"/></CustomTag>`
},
{
key: createUUID(),
value: `<CustomTag className="base-tag" color="#2396ef" value="标签1"/></CustomTag>这是一个自定义的标签组件:<CustomTag className="base-tag" color="#2396ef" value="标签2"/></CustomTag>`
},
]

嗯,这里涉及到了一个uuid方法,代码如下:

1
ts复制代码export const createUUID = () => (Math.random() * 10000000).toString(16).substr(0, 4) + '-' + (new Date()).getTime() + '-' + Math.random().toString().substr(2, 5);

接下来新建一个components目录,实现我们的2个组件CustomInput与CustomTag并写上一些样式,然后在app.tsx里面,我们就可以使用了,我们会使用createElement方法,如下所示:

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
ts复制代码import { createUUID } from './utils/uuid';
import { renderList } from './utils/data';
import { simpleJSXParser } from './utils/jsx-parser';
// ...
import CustomInput from './components/custom-input';
import CustomTag from './components/custom-tag';
// ...
// 定义自定义的组件对象
const components: Record<
string,
(props: Record<string, unknown>) => JSX.Element
> = {
CustomInput,
CustomTag
};
// app组件内部
const App = () => {
// 渲染列表
const renderItem = () => {
const list = renderList?.map(item => simpleJSXParser(item.value));
return list?.map(item => (
<Fragment key={createUUID()}>
{item?.map(com =>
createElement('div', { key: createUUID(), className: 'row' }, [
typeof com === 'string'
? com
: createElement(components[com.type], {
...com.props,
key: createUUID()
})
])
)}
</Fragment>
));
};
// ....
return <div>{/*....*/}{renderItem()}</div>
}

最终会得到如下图所示的渲染效果:

截屏2024-04-23 下午8.05.00.png

想要查看在线效果,可以点击这里查看,源码地址在这里。

感谢大家阅读本文,觉得本文不错,希望不吝啬点赞收藏。

本文转载自: 掘金

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

Jetpack Compose -> 重组的性能风险和优化

发表于 2024-04-23

前言


上一章我们讲解了 Jetpack Compose -> mutableStateOf 状态机制的背后秘密 本章我们讲解下重组的性能风险以及怎么优化;

重组的性能风险


前面我们一直在讲重组(ReCompose) 的过程,在使用 mutableStateOf() 以及对于 List 和 Map 在使用 mutatbleStateListOf()、mutableStateMapOf() 也能监听到内部状态变化,如果对于 List 和 Map 使用的是 mutableStateOf() 只能触发这个对象变化的监听;

重组其实分为:触发重组和重组,这是两个过程,触发重组是某个变量发生改变之后,Compose 去把已经组合好的那些部分重新的 Compose 一次,这个所谓的组合好的部分就是之前说的组合过程的结果;也就是那个稍后被拿去组合、测量、绘制的结果,它在相关的变量改变之后,是需要重新组合过程,重新生成结果的;

触发重组,就是 ReCompose Scope 重组范围;重组是在下一帧的时候去调用这些失效了的 compose 代码,来重新生成组合的过程;

因为 Column 函数是一个内联函数,所以 Column 函数在编译后会被抹除掉,也就是说,如果我们在使用下面的逻辑的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
kotlin复制代码private var number by mutableStateOf(1)

@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Android_VMTheme {
Surface {
println("Recompose 测试范围1")
Column {
println("Recompose 测试范围2")
Text(text = "当前数值是: $number", modifier = Modifier.clickable {
number ++
})
}
}
}
}
}

Column 会被抹除掉,而是直接将 Text 放到那里,也就是说如果发生了 ReCompose 的时候,是会把 Column 前后范围内的都会触发 Recompose;

也就是说当我们点击 Text 触发 number++ 的时候,会再一次打印 “Recompose 测试范围1” 和 “Recompose 测试范围2”;

这其实就是重组的性能风险;一个小的改动,触发了大面积的 ReCompose,这就造成了计算资源浪费;

我们来继续验证下这个结论,假设我们有下面这样的一段代码

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
kotlin复制代码private var number by mutableStateOf(1)

@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Android_VMTheme {
Surface {
println("Recompose 测试范围1")
Column {
testPerformance()
println("Recompose 测试范围2")
Text(text = "当前数值是: $number", modifier = Modifier.clickable {
number ++
})
}
}
}
}
}


@Composable
fun testPerformance() {
println("Recompose 测试范围 performance")
Text(text = "test")
}

按照上面的结论,当我们执行这段代码的时候,应该会打印 “Recompose 测试范围1” “Recompose 测试范围 performance 和 “Recompose 测试范围2”;

我们来运行看下,当我们点击 Text 的时候,发现并没有打印 “Recompose 测试范围 performance” 这一行输出,那么这是为什么呢?

难道这个 testPerformance 函数没有被调用吗?不是的,它被调用了,但是内部的逻辑没有执行,我们前面讲到过 Compose 的编译过程是由它的编译器 插件来干预的,这个干预过程会修改我们的 Composable 函数,会给函数增加一些参数,例如 Composer 函数会被添加进去,也会给你的函数添加一些条件判断进去,判断这个函数跟上一次调用传入的参数有没有改变,如果没有改变,就会跳过这个函数的内部执行逻辑,这是 Compose 的一种优化,它会避免在 ReCompose 的时候一些没必要的执行;

我们来看这个 testPerformance 函数,它在第二次被调用的时候,它的函数参数并没有发生变化,所以它内部的逻辑不会再执行;

如果我们给 testPerformance 函数增加一个参数

1
2
3
4
5
kotlin复制代码@Composable
fun testPerformance(text: String) {
println("Recompose 测试范围 performance")
Text(text = "test $text")
}

调用的时候,传入 number;

1
scss复制代码testPerformance(number.toString())

然后我们来重新执行一下,可以看到,结果是我们预期的结果了;Text 地方因为 number 的改变会被标记一次失效,同时 testPerformance 的调用地方因为也用到了 number,也会被标记一次失效,虽然标记了两次,但是这个是没有关系的,它只会执行一次重组,因为标记和重组是两个过程,它们是分开的,而且标记是个很轻的工作,它是不耗费什么计算资源的,所以不用担心性能,只会重组一次;

当传入一个 number 之后,这个 testPerformance 的重组就会从被动执行变成主动标记失效并执行的过程了;从执行角度来看是一样的,从标记角度来看,是从被动标记变成了主动标记的过程;

当 testPerformance 执行 ReCompose 的时候,Compose 发现它的函数变了,Compose 就会在第二次进入这个代码,所以打印就会执行;

这是 Compose 中很重要的一个性能优化点;那么问题来了,『这个性能优化是 Compose 相对传统 View 系统的写法的优势吗?』

答案显然不是的,传统写法是手动更新的,Compose 是自动更新的,而自动更新就会触发一个更新范围过大超过需求的问题,从而需要让你的框架去做这种跳过没有必要的更新的优化;这是针对过度更新的问题的优化,而不是相对传统 View 系统写法的优势

Compose 的重组在函数没有变化的时候,跳过函数的内部代码的执行,那肯定需要在 Recompose 的时候做一个对比,去比对 Compose 函数的值是否发生了改变,这个是否改变的判断,它是靠什么来判断的呢?

Structual Equality 结构性相等(Kotlin 的 ==)


这里额外提一个知识点,在 Kotlin 中 == 等于 Java 中的 equals,而 === 才等于 Java 的 ==;我们来验证下 Compose 在重组的时候是否是依赖的这种结构性相等来做出是否重组的决定;

我们来声明一个 data class;

1
kotlin复制代码data class User(val name: String)

testPerformance 修改如下:

1
2
3
4
5
kotlin复制代码@Composable
fun testPerformance(user: User) {
println("Recompose 测试范围 performance")
Text(text = "test $user.name")
}

调用的地方修改为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码private var number by mutableStateOf(1)
var user = User("Mars")

@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Android_VMTheme {
Surface {
println("Recompose 测试范围1")
Column {
testPerformance(user)
println("Recompose 测试范围2")
Text(text = "当前数值是: $number", modifier = Modifier.clickable {
number ++
user = User("Mars")
})
}
}
}
}
}

当我们执行点击事件,number++的时候,我们给 user 重新赋了一次值,当 ReCompose 的时候,testPerformance(user) 就会使用这个新创建的 user,如果不是使用的结构性相等的话,那么就会执行 testPerformance 中的打印,如果使用的是结构性相等,则不会打印;

我们运行看下,可以看到,并没有打印 testPerformance 中的日志,说明 Compose 在 ReCompose 使用的是结构性相等来判断是否要重组;

可靠的类 & 不可靠的类

我们接下来做另一个改动,把 name 的修饰符改成 var,其他地方不做改动;

1
kotlin复制代码data class User(var name: String)

我们来运行看下,可以看到,这次直接打印了 testPerformance 中的日志,说明 Compose 在 ReCompose 的时候发生了重组;

那么这是为什么呢?因为当我们使用 var 修饰符的时候,Kotlin 就认为这个类不可靠了,对于可靠的类,Compose 使用结构性相等来判断是否发生了改变,对于不可靠的类,Compose 就不判断,直接进入 Composeable 函数的内部,无脑执行了;

那么,问题来了,为什么一个 var 关键字就把这个类变成了不可靠的类了呢?

我们先来把上面这段代码做一个小小的改动;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
kotlin复制代码private var number by mutableStateOf(1)
val user1 = User("Mars")
val user2 = User("Mars")
var user = user1

@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Android_VMTheme {
Surface {
println("Recompose 测试范围1")
Column {
testPerformance(user)
println("Recompose 测试范围2")
Text(text = "当前数值是: $number", modifier = Modifier.clickable {
number ++
user = user2
})
}
}
}
}
}
1
kotlin复制代码data class User(var name: String)

我们来创建两个新的 User 分别是 user1 和 user2,这样写对于程序执行的结果是不变的, 当我们点击 Text 的时候,testPerformance 在 ReCompose 的时候也会改变,它的参数从 user1 变成了 user2,这个时候在 ReCompose 的时候,是会进入这个 testPerformance 函数内部的,因为 User 类的 name 是 var 类型的,一个神奇的存在,var 就会让其重组,我们假设它不会发生重组的场景下来推演下它会发生什么;

假设在重组的过程中,认为这个 user 没有改变,理由是它们通过 equals 判断认为是同一个对象,所以认为没变,就跳过了 testPerformance 这个函数的内部执行,这样显示不会出问题,但是如果在这之后,程序又在其他地方执行了一些其他逻辑,从其他地方把 user2 的 name 的值做了修改,这个是不会触发重组的,因为 user1 和 user2 并没有使用 mutableStateOf,但是如果又由于其他原因触发了 ReCompose 的行为,但是不是从外面这种捎带着往里的触发 testPerformance 的重组,而是直接触发了 testPerformance 内部的重组,那么当它内部独立的发生了 ReCompose 的时候,它内部显示的文字是不会改变的,因为它内部始终监听的是 user1 对象,虽然在
点击事件中 testPerformance(user1) 的 user1 被替换成了 user2 但是内部并没有发生重组,也就是对于 testPerformance 内部来说,它一直观测的都是 user1 这个旧的值,这样的话就算 testPerformance 内部发生了独立的 ReCompose,并且它应该观测的 user2 的 name 值也发生了改变,但是它内部的显示并不会去显示这个 user2 的 name 的最新值,而是显示那个本来应该被抛弃的 user1 的 name 的值;

简单来说:当 testPerformance 在 ReCompose 的时候,参数里面的 user 换成一个新的对象的时候,如果 Compose 用 equals 判断出来新对象和老对象是相等的,那就不进入 testPerformance 的内部代码了,虽然当下没有显示问题,并且看似节约了性能,但是会导致函数内部的后续的监听全部失效了,都监听了错的老的对象,而没有监听正确的新的对象,造成未来的显示问题;

所以 当我们使用 var 的时候,Compose 就无脑的直接进入了,它认为这个发生了改变;val 关键字修饰的字符串它的值是不可以修改的,而 User 类它的内部只有 name 这一个属性,如果这个 name 是不可变的,那么这个创建出来的 User 对象也是不会改变的;

也就是说:如果能保证现在相等,以后也相等,那么就不进入,如果不能保证现在相等,以后也相等,那么就无脑进入;

到这的时候,可能好多人就有疑问了,这个优化岂不是根本用不到,我们大部分使用的是 var 类型,还是会造成性能损耗;这其实是一个存在了好久的问题;

我们都使用过 HashMap,它在 put 元素的时候,通过 hashCode() 和 equals() 来判断 key 的冲突,当我们使用一个对象作为 key 的时候:

1
2
3
sql复制代码HashMap<User, String> hashMap = new HashMap();
User user = new User("Mars"); // 自己实现了 hashCode() equals()
hashMap.put(user, "1")

当我们使用一个对象作为 key 的时候,一定要确定它的 hashCode 值是不可变的,如果我们使用的是一个 data class,它的 hashCode 和 equals 都是和它内部的值有关系的,如果我们使用一个 data class 来作为 key 就要保证它的值是不可变的,

@Stable


那么针对上面的问题,我们怎么解决呢? Compose 提供了一种方案,『@Stable』注解,如果你给 User 类添加这么一个注解,Compose 在 ReCompose 的时候就会跳过;

1
2
kotlin复制代码@Stable
data class User(var name: String)

这个 @Stable 是一个稳定性标记,加上这个注解就是在告诉 Compose 编译器插件,这个类型是可靠的,不用检查,由人工来保证;

但是人工保证并不能做到绝对,程序还是可能会出现问题,那么我们怎么处理呢?就是让它不相等,也就是我们不去重写 equals 方法,而是采用它本身的 equals 逻辑;

1
2
kotlin复制代码@Stable
class User(var name: String)

当我们使用的是同一个对象的时候,可以让 Compose 编译器插件不执行检查;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
kotlin复制代码private var number by mutableStateOf(1)
// val user1 = User("Mars")
// val user2 = User("Mars")
var user = User("Mars")

@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Android_VMTheme {
Surface {
println("Recompose 测试范围1")
Column {
testPerformance(user)
println("Recompose 测试范围2")
Text(text = "当前数值是: $number", modifier = Modifier.clickable {
number ++
// user = user2
})
}
}
}
}
}

当我们点击 Text 的时候,没有给 user 重新赋值,但是我们使用 『@Stable』注解标记了这个 User 类,那么就不会执行内部的检查;

另外,@Stable 的稳定,需要满足下面三点

  1. 现在相等就永远相等;
  2. 当公开属性改变的时候,要通知到用这个属性的 Composition
  3. 公开属性,也必须全都是可靠类型,或者说稳定类型

那么怎么通知到 Composition 呢?很简单,就是通过 mutableStateOf;

1
2
3
4
kotlin复制代码@Stable
class User(name: String) {
var name by mutableStateOf(name)
}

针对第三点,我们来看下面的代码

1
2
3
4
5
6
kotlin复制代码class Company(var address: String)

class User(name: String, company: Company) {
var name by mutableStateOf(name)
var company by mutableStateOf(company)
}

因为 company 是一个不稳定的属性,所以它就会导致 User 成为一个不稳定的属性,哪怕是使用了 mutableStateOf;

而 Compose 只会判断第二条,只要满足第二条,它就会认为稳定;

好了,今天的 Compose 就到这里吧;

下一章预告


derivedStateOf 与 remember() 有什么区别?

欢迎三连


来都来了,点个关注点个赞吧,你的支持是我最大的动力~

本文转载自: 掘金

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

1…272829…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%