macOS 录屏软件开发实录:从像素抓取到元数据重现
视频正在取代文字成为主流的表达方式,而好工具是创作的加速器。macOS 录屏软件 ScreenSage Pro 的独立开发者 Sintone 分享了从像素抓取到元数据重现的全过程。从屏幕录制、元数据捕获,到高性能视频合成,他详述了开发中的挑战与解决方案。
视频正在取代文字成为主流的表达方式,而好工具是创作的加速器。macOS 录屏软件 ScreenSage Pro 的独立开发者 Sintone 分享了从像素抓取到元数据重现的全过程。从屏幕录制、元数据捕获,到高性能视频合成,他详述了开发中的挑战与解决方案。
![]()
2 月 4 日,iQOO 发布了 iQOO 15 系列新品,同时也是 iQOO 数字系列第一款带上「超大杯」后缀的手机——iQOO 15 Ultra,定价5699 元,首销价 5499 元,国补到手价 4999 元
![]()
新机延续了 15 系列方正干净的风格,手感丝滑的 AF 镀膜工艺磨砂质感后盖搭配大 R 角,中框边缘往内收了 1.2mm 提升衔接效果,裸机手持的感觉很舒服。
iQOO 提供了两款配色,分别是致敬《赛博朋克 2077》,以黑橙色为主的 2077 配色,以及致敬《银翼杀手 2049》,机身以有点偏冷的哑光银作为主调的 2049 配色。
![]()
后盖用上了最新的 Texture on Fiber TOF 工艺,可以将纹理、镀膜、打印等工艺集成在 PET 膜片上,实现表面双镀膜的效果,实现动态纹理的视效。
手机背后的六角形纹理会顺着不同的观看角度、不同的观影效果变化,实际上看起来会有种类似能量条充能的效果,十分有趣。
![]()
DECO 换成了更加方正的「未来舱」设计,透明面板下能看到三摄被分别固定在各自的方块上,除了实现了官方说的悬浮感,模块分明的设计加上背后条形纹理细节,提升了 DECO 的机械感。
![]()
潜望式长焦模块侧边上方的位置有一条 Monster Halo 呼吸灯,它能够根据内置风扇的设置呈现不同的光效,对应《金铲铲之战》和《王者荣耀》的专属光效。
![]()
来到机身侧边,iQOO 15 Ultra 再次加入了肩键设计。
这款超感游戏肩键采用了双独立触控控制芯片降低延迟,最高支持 600Hz 触控采样率,还有防手汗的算法来提升精度,对《和平精英》、《三角洲行动》这一类射击游戏,扳机可以移交到肩键上,移动和视角控制交给拇指触控,操作起来会更舒服。
加上,iQOO 15 Ultra 的肩键位置比较靠内,手握持时可以紧贴机身,这样拿着手机更稳定,更有操控横向掌机的感觉。
![]()
iQOO 15 Ultra 机身正面搭载了一块 6.85 英寸 3168 x 1440 的 2K 专业电竞屏。
它最高支持 144Hz 的刷新率,手动峰值亮度是1000nits,全局峰值亮度为 2600nits,局部峰值亮度更是提升到 8000nits。自身支持三光敏全域感光,提升屏幕的感光性能。还有 98.1% 首帧亮度占比,降低屏幕拖影。
护眼部分,这块屏幕支持高亮度 DC 调光,低亮度 2160Hz 高频 PWM 调光,有无偏振自然光显示、1nit 最低亮度以及护目护眼 2.0,还有莱茵的游戏、无偏振显示、无频闪认证。
![]()
配置部分,iQOO 15 Ultra 搭载了第五代高通骁龙 8 至尊版移动平台,搭配自研电竞芯片 Q3,还有 LPDDR5X Ultra Pro 和 UFS4.1 的组合,最高支持 24GB RAM+1TB ROM,顶配的话就是真正的性能超大杯了。在常规的室内环境下,安兔兔跑分为 4452860。
提升性能的同时,iQOO 这次还重点加强了散热。
![]()
iQOO 15 Ultra 内部搭载了冰穹主动散热风扇,风扇尺寸为 17 x 17 x 4mm,有 59 片扇片,单体最大出风量为 0.315CFM,有三档可选,也能够只能调节,对应不同程度的使用场景。机内还内置 8000mm² VC 均热板,进一步提升散热效能。
风扇内部加入了叶片防尘环设计,隐藏在 DECO 下侧的进风口上加入双防尘网设计,可以有效防止尘进入内部。电路板双层有防水处理,不影响机身的防水防尘性能,整机支持 IP68&IP69 的防水防尘。
![]()
在高性能配置和加入主动散热结构的作用下,iQOO 15 Ultra 应付《王者荣耀》、《和平精英》、《三角洲行动》以及《暗区突围》等主流的游戏还是很轻松的,《王者荣耀》在 144FPS + 极致画质的状态下支持档位全开,《三角洲行动》支持 144FPS + 极致画质,《暗区突围》支持原生光追加上 144FPS + 极致画质,配置上可以直接带满。
![]()
《原神》和《绝区零》可以开启全场景的光追加上 2K 原画超分+ 120 超帧顶格并发,开启 Monster 模式之后操作的确要流畅不少,只要不是刚刚进入的新图新区域,手机的表现还是很稳定的。
而且,在游戏刚刚下载完整数据包的状态下,iQOO 15 Ultra 加载到进入游戏的过程也会比不少同定位的机型快不少,由此可见游戏方面的表现是真的不错。
![]()
游戏助手的设置也很丰富,有常规的画质、帧数设置、Q3 电竞芯片的针对性设置,风扇的速度模式也可以在侧拉菜单里快速调节。
操控的部分,iQOO 15 Ultra 内置超感触控芯片,支持 480Hz 多指触控采样和最高瞬时 4000Hz 触控采样率,以及 27.1ms 的最低点击触控延迟、29.5ms 滑动触控跟手延迟。配合内置的超感陀螺仪和触控区域适配优化,用它玩《三角洲行动》确实要更准,日常「吃鸡」压枪更稳。
iQOO 15 Ultra 内置了集成 X 轴、Z 轴的战锤 MAX 双震感马达,《三角洲行动》、《崩坏:星穹铁道》有三档振感强度自定义,《原神》、《王者荣耀》、《QQ 飞车》三款游戏支持双轴振感,按压操作时的反馈会更丰富。
还有,手机支持游戏内智能录制和高光回放功能,《王者荣耀》、《和平精英》、《三角洲行动》、《暗区突围》、《无畏契约》、《穿越火线》、《使命召唤 M》支持 144FPS 直播,也有 2K 60fps 30Mbps 的手机直播模式,方便游戏内容创作者。
![]()
续航方面,iQOO 15 Ultra 搭载了一块 7400mAh 电池,日常使用的话大概 2 天左右,游戏比较多的话也能够坚持 1.5 天,算是现在比较常规的续航表现。
手机支持 100W 超快闪充,官方有线快充 0-100 时间是 65 分钟。我们用AI 小电拼进行测试,iQOO 15 Ultra 最高录得充电功率为 49W,0-100 充电耗时 77 分钟。
![]()
两者的差距主要体现在充电的后半段,不管是官方还是他家充电器都能够在 30 分钟内充满一半,40 分钟能来到 70% 以上,出门在外基本不需要绑定官方的单口快充头了。
![]()
不过,这一代 iQOO 又加入了胶囊造型设计的弯头 USB-C to C 充电线,手持打游戏的时候不用躲充电头。配合机器自身的旁路供电功能,用着还是挺舒服的。
iQOO 15 Ultra 还支持 40W 无线充电,在家配合官方充电器,出门在外配上一些磁吸配件用磁吸充电宝也很方便。
![]()
最后看看相机,旗舰定位的 iQOO 15 Ultra 给出了 5000 万三摄的标准组合:
相机内支持 NICE 3.0 光学重构引擎和 Magic 2.0 画质还原引擎,提升长焦画质。「原生光影」 算法更新,提升了拍摄的自然度,去除 AI 后感。
iQOO 还更新了「撕拉片」风格滤镜和新的 AI 视效「AI 风光大师」,在增加机身拍摄的风格和玩法。
![]()
最后看看定价,iQOO 15 Ultra 有四个储存版本,全线 16GB 运存起步:
#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。
功能做完了,但用户体验还差一步——当网络出错、接口超时、数据异常时,应用会不会崩溃?用户能不能看到友好的提示?这篇文章以心动恋聊小程序为例,展示如何和 AI 对话,构建健壮、友好、可维护的错误处理体系。
系列专栏:【AI 编程实战】专栏目录
本篇主题:让应用更健壮 - 错误处理与边界情况
实战项目:心动恋聊 - AI 恋爱聊天助手
// ❌ 危险:没有任何错误处理
const handleGenerate = async () => {
const response = await chatService.generateReply({ text: inputText.value });
replies.value = response.data.replies;
};
可能出现的问题:
Cannot read property 'replies' of undefined
// ✅ 健壮:完整的错误处理
const handleGenerate = async () => {
if (loading.value) return; // 防重复点击
loading.value = true;
try {
const response = await chatService.generateReply({ text: inputText.value });
if (response.code !== 200) {
throw new Error(response.msg || '生成失败');
}
replies.value = response.data?.replies ?? []; // 空值保护
} catch (error) {
const errMsg = error?.message || '网络错误,请稍后再试';
toast.error(errMsg);
console.error('生成回复失败:', error);
} finally {
loading.value = false;
}
};
| 问题 | 解决方案 |
|---|---|
| 网络错误 | 统一错误提示 + 重试机制 |
| 接口错误码 | 分类处理(401/403/500) |
| 空值异常 | 可选链 + 空值合并 |
| 重复点击 | loading 状态锁定 |
| 用户取消 | 区分取消和真正的错误 |
我:项目里很多地方要显示提示,有成功、失败、警告等类型。
每次都写 uni.showToast 太繁琐,能不能封装一下?
AI:封装一个 toast 工具,提供链式调用:
📐 接口设计:
toast.success('操作成功')
toast.error('操作失败')
toast.warning('请注意')
toast.info('提示信息')
📐 配置选项:
interface ToastOptions {
duration?: number; // 显示时长,默认 2000ms
position?: 'top' | 'middle' | 'bottom'; // 位置
mask?: boolean; // 是否显示遮罩防止穿透
}
// src/utils/toast.ts
type ToastType = 'success' | 'error' | 'warning' | 'info';
interface ToastOptions {
type?: ToastType;
duration?: number;
position?: 'top' | 'middle' | 'bottom';
message: string;
mask?: boolean;
}
export function showToast(message: string): void;
export function showToast(options: ToastOptions): void;
export function showToast(options: ToastOptions | string): void {
const defaultOptions: ToastOptions = {
type: 'info',
duration: 2000,
position: 'middle',
message: '',
mask: false,
};
const mergedOptions = typeof options === 'string'
? { ...defaultOptions, message: options }
: { ...defaultOptions, ...options };
// 映射 position 到 UniApp 支持的格式
const positionMap = {
top: 'top',
middle: 'center',
bottom: 'bottom',
};
uni.showToast({
title: mergedOptions.message,
duration: mergedOptions.duration,
position: positionMap[mergedOptions.position ?? 'middle'],
icon: 'none',
mask: mergedOptions.mask,
});
}
// 便捷方法
type SimpleToastOptions = Omit<ToastOptions, 'type' | 'message'>;
export const toast = {
success: (message: string, options?: SimpleToastOptions) =>
showToast({ ...options, type: 'success', message }),
error: (message: string, options?: SimpleToastOptions) =>
showToast({ ...options, type: 'error', message }),
warning: (message: string, options?: SimpleToastOptions) =>
showToast({ ...options, type: 'warning', message }),
info: (message: string, options?: SimpleToastOptions) =>
showToast({ ...options, type: 'info', message }),
};
import { toast } from '@/utils/toast';
// 简单用法
toast.success('保存成功');
toast.error('网络错误,请稍后再试');
// 带配置
toast.info('正在处理...', { duration: 3000, mask: true });
我:后端返回不同的错误码:
- 401:未登录或 token 过期
- 403:没有权限(如 VIP 功能)
- 500:服务器错误
不同错误码要有不同的处理逻辑。
AI:在 HTTP 层统一处理常见错误码,业务层处理特殊错误:
📐 错误分类:
【HTTP 层处理】(在 http.ts 中)
- 401 → 清除登录态,跳转登录
- 网络错误 → 显示"网络错误"提示
【业务层处理】(在页面中)
- 403 → 引导开通 VIP
- 业务错误 → 显示具体错误信息
// src/http/http.ts
export function http<T>(options: CustomRequestOptions) {
return new Promise<IResData<T>>((resolve, reject) => {
uni.request({
...options,
success(res) {
// 2xx 状态码视为成功
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data as IResData<T>);
}
// 401 未授权
else if (res.statusCode === 401) {
// 清除登录态
uni.removeStorageSync('token');
// 可以触发全局事件或跳转登录
reject(res);
}
// 其他错误
else {
if (!options.hideErrorToast) {
const data = res.data as IResData<T>;
uni.showToast({
icon: 'none',
title: data.msg || '请求错误',
});
}
reject(res);
}
},
// 网络错误
fail(err) {
uni.showToast({
icon: 'none',
title: '网络错误,换个网络试试',
});
reject(err);
},
});
});
}
// 页面中处理 403 等业务错误
const handleGenerate = async () => {
loading.value = true;
try {
const response = await chatService.generateReply(params);
if (response.code !== 200) {
throw new Error(response.msg || '生成失败');
}
replies.value = response.data?.replies ?? [];
} catch (error: any) {
console.error('生成回复失败:', error);
// 提取错误信息
const errMsg = error?.data?.message
|| error?.msg
|| error?.message
|| '生成失败,请稍后再试';
// 根据状态码分类处理
const statusCode = error?.statusCode || error?.data?.code || error?.code;
if (statusCode === 403) {
// 权限不足,引导开通 VIP
const message = errMsg || '免费次数已用完,请开通VIP';
promptOpenVip(message);
} else {
// 其他错误,显示提示
toast.error(errMsg);
}
} finally {
loading.value = false;
}
};
// ❌ 危险:可能报错
const username = response.data.user.username;
// 如果 response.data 是 undefined,就会报错
const firstReply = replies[0].text;
// 如果 replies 是空数组,就会报错
AI:用可选链(?.)和空值合并(??)来保护:
📐 防御性编程规则:
1. 访问对象属性 → 用 ?.
2. 提供默认值 → 用 ??
3. 数组访问 → 先检查长度或用 ?.[0]
// ✅ 安全:可选链 + 空值合并
const username = response?.data?.user?.username ?? '未知用户';
// ✅ 安全:数组保护
const replies = response.data?.replies ?? [];
const firstReply = replies.length > 0 ? replies[0].text : '';
// ✅ 安全:解构时提供默认值
const { data = {} } = response ?? {};
const { replies = [], analysis = '' } = data;
// ✅ 安全:函数参数默认值
const updateUserInfo = (info: Partial<UserInfo> = {}) => {
// info 一定是对象,不会是 undefined
};
// 生成回复的完整错误处理
const handleGenerate = async () => {
// ... 请求逻辑
const data = response.data ?? {};
// 安全提取分析结果
analysisResult.value = data.analysis || '';
// 安全提取回复列表,并添加 id
const repliesFromApi = Array.isArray(data.replies)
? data.replies.slice(0, MEMORY_REPLY_COUNT)
: [];
replies.value = repliesFromApi.map((r, idx) => ({
...r,
id: r.id ?? idx, // 如果没有 id,用索引代替
}));
// 安全更新配额信息
userStore.updateFreeQuota({
free_reply_total: data.freeReplyTotal,
free_reply_used: data.freeReplyUsed,
free_reply_remaining: data.remainingTimes,
vip_status: data.isVip ? 'vip' : 'none',
vip_expire_time: data.vipExpireTime ?? null,
});
};
我:用户选择图片时点了"取消",会触发 fail 回调,
显示"图片选择失败"的错误提示。
但这不是真正的错误,不应该提示。
AI:通过错误信息来区分用户取消和真正的错误:
📊 错误信息分析:
【用户取消】
- chooseMedia:fail cancel
- chooseImage:fail cancel
- chooseImage:fail(code: 0)
【真正的错误】
- chooseImage:fail no permission
- chooseMedia:fail system error
- 其他错误信息
const addImages = () => {
const { run } = useUpload('', {}, {
count: remainingCount,
selectOnly: true,
onSelect: (images) => {
// 正常处理选中的图片
images.forEach((img) => uploadChosenImage(img));
},
onError: (err) => {
// 判断是否为用户主动取消
const errMsg = typeof err === 'object' && err !== null && 'errMsg' in err
? (err as any).errMsg
: '';
const errCode = typeof err === 'object' && err !== null && 'code' in err
? (err as any).code
: -1;
const isUserCancel =
errMsg === 'chooseMedia:fail cancel' ||
errMsg === 'chooseImage:fail cancel' ||
(errMsg === 'chooseImage:fail' && errCode === 0);
if (!isUserCancel) {
// 只有真正的错误才提示
console.error('图片选择失败:', err);
toast.error('图片选择失败');
}
// 用户取消时静默处理,不显示任何提示
},
});
run();
};
我:uni.login 等 API 用的是回调风格,写起来很繁琐。
能不能封装成 Promise 风格?
// src/utils/wechat.ts
/**
* 获取微信登录凭证
* 将回调风格封装成 Promise
*/
export const requestWechatLoginCode = () =>
new Promise<string>((resolve, reject) => {
uni.login({
provider: 'weixin',
onlyAuthorize: true,
success: (res) => {
if (res?.code) {
resolve(res.code);
} else {
reject(new Error('未获取到有效的登录凭证'));
}
},
fail: (err) => {
reject(err);
},
});
});
// 使用时
try {
const code = await requestWechatLoginCode();
await userStore.wechatLogin(code);
} catch (error) {
toast.error('登录失败');
}
// src/utils/clipboard.ts
import { isApp } from '@/utils/platform';
declare const plus: any;
/**
* 跨平台复制文本到剪贴板
*/
export function copyText(text: string): Promise<void> {
// App 端使用 plus API
if (isApp && typeof plus !== 'undefined' && plus?.navigator) {
return new Promise((resolve, reject) => {
try {
plus.navigator.setClipboard(text);
resolve();
} catch (error) {
reject(error);
}
});
}
// 小程序/H5 使用 uni API
return new Promise((resolve, reject) => {
uni.setClipboardData({
data: text,
success: () => resolve(),
fail: (err) => reject(err),
});
});
}
// 使用时
const handleCopy = async (text: string) => {
try {
await copyText(text);
toast.success('已复制');
} catch (error) {
console.error('复制失败:', error);
toast.error('复制失败,请稍后再试');
}
};
const loading = ref(false);
const handleSubmit = async () => {
// 防止重复点击
if (loading.value) return;
loading.value = true;
try {
await doSubmit();
} finally {
loading.value = false;
}
};
<template>
<XButton
:text="loading ? '处理中...' : '提交'"
:loading="loading"
:disabled="loading || !canSubmit"
@click="handleSubmit"
/>
</template>
// 页面有多个独立的加载状态
const isGenerating = ref(false); // 生成回复
const isUploadingImages = ref(false); // 上传图片
const isSaving = ref(false); // 保存数据
// 计算总体加载状态
const isLoading = computed(() =>
isGenerating.value || isUploadingImages.value || isSaving.value
);
// 细粒度控制按钮状态
<XButton
:disabled="isGenerating || isUploadingImages"
@click="handleGenerate"
/>
| 层级 | 处理内容 | 示例 |
|---|---|---|
| HTTP 层 | 网络错误、401 | http.ts 统一处理 |
| 业务层 | 403、业务错误码 | 页面 catch 块处理 |
| UI 层 | 用户提示、状态恢复 | toast + loading |
// 标准的异步操作模板
const handleAsyncAction = async () => {
// 1. 防重复
if (loading.value) return;
// 2. 前置校验
if (!canSubmit.value) {
toast.warning('请填写完整信息');
return;
}
loading.value = true;
try {
// 3. 执行操作
const response = await doAction();
// 4. 校验响应
if (response.code !== 200) {
throw new Error(response.msg || '操作失败');
}
// 5. 处理成功
const data = response.data ?? {};
processData(data);
toast.success('操作成功');
} catch (error: any) {
// 6. 分类处理错误
console.error('操作失败:', error);
const statusCode = error?.statusCode || error?.code;
const errMsg = error?.msg || error?.message || '操作失败';
if (statusCode === 403) {
handleNoPermission();
} else {
toast.error(errMsg);
}
} finally {
// 7. 恢复状态
loading.value = false;
}
};
| 技术 | 用途 | 示例 |
|---|---|---|
| try-catch-finally | 捕获和恢复 | 异步操作的标准模式 |
可选链 ?.
|
安全访问属性 | response?.data?.user |
空值合并 ??
|
提供默认值 | value ?? defaultValue |
| Promise 封装 | 统一回调风格 | requestWechatLoginCode() |
| 错误分类 | 差异化处理 | 401/403/500 不同处理 |
| Loading 状态 | 防重复 + 用户反馈 | if (loading) return |
错误处理不是"事后补救",而是设计阶段就要考虑的架构问题。
好的错误处理让用户感受到应用的专业和可靠。
如果这篇文章对你有帮助,请点赞、收藏、转发!
这里使用一个例子docker-compose.yml
// docker-compose.yml
# 确保所有服务在同一网络
version: '3.8'
services:
app:
networks:
- mynetwork
depends_on:
- mysql
- redis
mysql:
networks:
- mynetwork
redis:
networks:
- mynetwork
imotor-ltq:
image: phm-ltq:1.0.0
container_name: imotor-ltq
restart: always
networks:
- app_net
deploy:
resources:
limits:
cpus: '3.00'
memory: 5G
reservations:
cpus: '3.00'
memory: 2G
ports:
- "7953:8080"
spectral-ltq-web:
image: spectral-ltq-web:1.0.0
container_name: spectral-ltq-web
restart: always
networks:
- app_net
deploy:
resources:
limits:
cpus: '1.00'
memory: 1G
reservations:
cpus: '1.00'
memory: 1G
ports:
- "7777:8080"
depends_on:
imotor-algo:
condition: service_healthy
networks:
mynetwork:
driver: bridge
前端nginx 镜像容器配置:
location /api/ { # 使用变量强制每次解析 proxy_pass http://imotor-ltq:7953/; proxy_set_header Host $proxy_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header remote_addr $remote_addr; proxy_http_version 1.1; proxy_connect_timeout 4s; proxy_read_timeout 600s; proxy_send_timeout 12s; }
当后端开发新功能发版后偶现
![]()
发现服务不能使用了,每次出现这个问题时 都需要重启前端项目,
后面开始排查:查看docker 网络
docker network ls
docker network inspect <network_name>
![]()
多次重启后发现IP地址会发生变化,基本可以确定是使用服务名DNS解析时存在缓存
使用的是之前的服务IP地址,所以找不到
进一步确定问题:使用 docker-compose exec 前端应用 ping imotor-ltq
服务
解决方法:
server {
listen 8080;# 访问端口
server_name localhost;
resolver 127.0.0.11 valid=10s; # Docker 内置 DNS
location /api/ {
set $algo_host imotor-algo;
rewrite ^/api/(.*)$ /$1 break; //使用rewrite重置为后端需要的地址
proxy_pass http://$algo_host:8080;
}
}
// 添加 resolver 127.0.0.11 valid=10s; # Docker 内置 DNS
使用变量
set $algo_host imotor-algo;
rewrite ^/api/(.*)/1 break;
proxy_pass http://$algo_host:8080;
之前想省略 rewrite ^/api/(.*)/1 break;直接在后面加/
set $algo_host imotor-algo;
proxy_pass http://$algo_host:8080/;
发现找不到服务地址,不知道什么原因