普通视图
前端轮子(1)--前端部署后-判断页面是否为最新
前端轮子系列:
- 前端部署后-判断页面是否为最新
- 如何优雅diy响应数据
- 如何优雅进行webview调试
- 如何实现测试环境的自动登录
项目部署后:通知更新、强制刷新、自测访问的页面为最新代码(是否发版成功)
重点:自测、提测、修改bug后,确认访问的为最新页面
![]()
实现思路
目的:生成新的版本标识,通过对比标识 ==> 当前为最新 还是 旧页面
- 生成版本号:如:1.1.10 ,或者时间戳(202512201416)
- 自动化脚本生成
- 手动控制
- 对比版本号:相等=> 当前为最新;不等 => 当前为旧页面
- 通过对比结果 对应处理产品逻辑 完事儿~
具体实现
方案一:生成时间戳,注入变量,控制台打印
在vue-cli项目中
通过vue.config.js 入变量
然后在main.js中打印变量
// main.js
console.log('__BUILD_TIME__', __BUILD_TIME__)
// vue.config.js
const _date = new Date()
const BUILD_TIME = _date.toLocaleString ? _date.toLocaleString() : _date.getTime()
module.exports = {
configureWebpack: {
plugins: [
new webpack.DefinePlugin({
__BUILD_TIME__: JSON.stringify(BUILD_TIME)
})
]
},
}
在vite项目中
通过vite.config.js 注入 BUILD_TIME 变量
然后在main.js中打印 BUILD_TIME 变量
// main.js
console.log('__BUILD_TIME__', __BUILD_TIME__)
// vite.config.js
const _date = new Date()
const BUILD_TIME = _date.toLocaleString ? _date.toLocaleString() : _date.getTime()
export default defineConfig(() => {
return {
define: {
__BUILD_TIME__: JSON.stringify(BUILD_TIME)
},
}
})
这种方案,实现了在控制台打印。但是对于生产环境,无法做到版本号的对比
为啥? 因为上线 一般会去掉日志的打印, 所以咱通过版本号来~
方案二:记录版本号,轮询对比版本号
- 编写生成版本号的脚本,生成版本号文件
- build结束,调用脚本去生成版本号文件
- 轮询对比版本号文件,如果版本号不一致,做相应操作
// dist/version.json
{
"buildTime": "2025-12-20 14:16:00"
}
prebuild: build前执行的脚本
postbuild:build结束后执行的脚本,用于生成版本号文件
// package.json
{
"scripts": {
"postbuild": "node scripts/generate-version-dist.mjs"
}
}
// scripts/generate-version-dist.mjs
import { writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const root = path.resolve(__dirname, '..')
async function main() {
const _date = new Date()
const BUILD_TIME = _date.toLocaleString ? _date.toLocaleString() : _date.getTime()
const outPath = path.join(root, 'dist', 'version.json')
await writeFile(outPath, JSON.stringify({ BUILD_TIME }, null, 2) + '\n', 'utf-8')
}
main().catch((e) => {
console.error('[generate-version] failed', e)
process.exit(1)
})
轮询对比版本号
- 轮询对比版本号文件,如果版本号不一致,做相应操作
- 页面隐藏、切后台时,停止轮询,页面关闭时,停止轮询
- 页面显示、切前台时,开始轮询
// main.js
import { createVersionPoller } from './utils/versionPoller'
createVersionPoller({
versionUrl: '/version.json',
storageKey: '__VERSION_CHECK__CURRENT_BUILD_TIME__',
intervalMs: 15_000,
maxDelayMs: 60_000,
onResult: ({ remoteVersion, remoteBuildTime }) => {
// 轮询到json文件 成功,额外处理逻辑
},
onError: (e) => {
// 轮询失败,额外处理逻辑
},
onUpdate: () => {
// 版本号不一致,需要更新,额外处理逻辑
}
}).start()
/**
* 轮询 /version.json,对比 buildTime 判断是否需要更新(setTimeout + 错误指数退让)
*
* 行为说明:
* - start() 会自动注册监听:visibilitychange / beforeunload / unload
* - stop() 会清理定时器并卸载监听
* - 请求失败会指数退让(delay *= 2,最大不超过 maxDelayMs);成功后恢复 intervalMs
* - currentBuildTime 会写入 localStorage(storageKey)
*
* @param {Object} options
* @param {string} [options.versionUrl='/version.json'] 版本文件地址
* @param {string} [options.storageKey='__VERSION_CHECK__CURRENT_BUILD_TIME__'] localStorage key
* @param {number} [options.intervalMs=15000] 正常轮询间隔(毫秒)
* @param {number} [options.maxDelayMs=60000] 退让最大间隔(毫秒)
* @param {(info:{localBuildTime:string,remoteBuildTime:string,remoteVersion:string,data:any})=>void} [options.onResult] 每次成功拉取后的回调
* @param {(error:any)=>void} [options.onError] 拉取失败回调
* @param {(info:{localBuildTime:string,remoteBuildTime:string,remoteVersion:string,data:any})=>void} [options.onUpdate] 发现新 buildTime 时回调(默认会 stop)
*/
export function createVersionPoller({
versionUrl = '/version.json',
storageKey = '__VERSION_CHECK__CURRENT_BUILD_TIME__',
intervalMs = 15000,
maxDelayMs = 60000,
onResult,
onError,
onUpdate
} = {}) {
let timer = null
let stopped = true
let delayMs = intervalMs
let bound = false
const readLocal = () => window.localStorage.getItem(storageKey) || ''
const writeLocal = (v) => v && window.localStorage.setItem(storageKey, String(v))
function clear() {
if (!timer) return
window.clearTimeout(timer)
timer = null
}
function isVisible() {
return document.visibilityState !== 'hidden'
}
async function fetchRemote() {
const sep = versionUrl.includes('?') ? '&' : '?'
const url = `${versionUrl}${sep}t=${Date.now()}`
const res = await fetch(url, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } })
if (!res.ok) throw new Error(`fetch ${versionUrl} failed: ${res.status}`)
return await res.json()
}
async function tick() {
if (stopped || !isVisible()) return
const localBuildTime = readLocal() || ''
try {
const data = await fetchRemote()
const remoteVersion = String(data?.version || '')
const remoteBuildTime = String(data?.BUILD_TIME || '')
delayMs = intervalMs
onResult?.({ localBuildTime, remoteBuildTime, remoteVersion, data })
console.log( remoteBuildTime , '=>', localBuildTime)
if (remoteBuildTime && remoteBuildTime !== localBuildTime) {
// 记录最新 buildTime,避免重复提示同一个更新
writeLocal(remoteBuildTime)
onUpdate?.({ localBuildTime, remoteBuildTime, remoteVersion, data })
}
} catch (e) {
delayMs = Math.min(maxDelayMs, Math.max(500, delayMs * 2))
onError?.(e)
}
timer = window.setTimeout(tick, delayMs)
}
function onVisibilityChange() {
if (stopped) return
if (!isVisible()) return clear()
clear()
timer = window.setTimeout(tick, 0)
}
function ensureBound() {
if (bound) return
bound = true
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('beforeunload', stop)
window.addEventListener('unload', stop)
}
function ensureUnbound() {
if (!bound) return
bound = false
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('beforeunload', stop)
window.removeEventListener('unload', stop)
}
function start() {
if (!stopped) return
stopped = false
delayMs = intervalMs
ensureBound()
if (isVisible()) timer = window.setTimeout(tick, 0)
}
function stop() {
stopped = true
clear()
ensureUnbound()
}
return { start, stop }
}
注意点
- 轮询版本文件,拼 时间戳、设置请求头 避免命中缓存
- 轮询版本文件,时间间隔不要太短,耗费网络,虽然已经做了轮询指数退让
- 生成的版本文件 生成到dist下 注意访问路径
- 版本号需要上传到git,需要手动执行脚本&commit + push
源码
结语
如果本文对你有收获,麻烦动动发财的小手,点点关注、点点赞!!!👻👻👻
因为收藏===会了
如果有不对、更好的方式实现、可以优化的地方欢迎在评论区指出,谢谢👾👾👾
融资丨朗毅机器人连续完成两轮数千万元融资
近日,全球领先的具身导航大脑服务商——朗毅机器人宣布完成数千万元天使+轮融资。继今年7月完成天使轮融资后,半年内朗毅机器人再次获得资本市场青睐。天使轮和天使+轮融资已陆续引入英诺天使基金、嘉道资本、光谷金控、江阴人才基金、奇绩创坛等知名机构投资,不仅为朗毅机器人注入“资金活水”,更凭借多方投资方的产业资源协同,为其具身导航技术的规模化落地按下“加速键”。
两轮融资资金将重点聚焦具身导航模组的研发与量产、空间智能算法的升级迭代、多场景规模化拓展,以及与头部机器人厂商的生态共建升级,全力推动机器人从“功能演示”向“全自主实用”跨越。朗毅的具身导航模组已适配多款具身机器人,成功落地工业制造、安防巡检、商业服务等多元场景。
技术迭代升级,空间智能不断突破朗毅机器人是全球首个实现人形机器人自主导航方案的企业,持续升级迭代空间智能算法,不断提高技术壁垒。
全球首个实现人形机器人自主导航方案:在2024年,朗毅机器人既已推出30余个自由度的全尺寸人形的全自主导航系统,攻克了震动剧烈、算力有限、自由度多、延迟大、非线性极强等系统难点,当人形机器人普遍被质疑为“遥控玩具”之际,朗毅成为业内首家实现人形机器人彻底摆脱遥控器的核心供应商,该技术优势持续领跑全球。
生态协作,行业市占率第一,场景广度第一:朗毅机器人秉持生态共建理念,已与数十家头部机器人本体厂商达成深度合作,实现商业化落地。朗毅推出的软硬件一体具身导航模组——灵睛智能感知导航系统,不仅体积小、场景适应性极强,适配速度更仅需1-3日。在2025WRC(世界机器人大会)与2025WAIC(世界人工智能大会)上,朗毅的灵睛智能感知导航系统已成为多家参展企业的核心技术支撑,其技术实力在现场静态及动态展示中充分彰显。目前,朗毅正与合作伙伴开展场景落地深度联动,确保技术迭代与行业需求实时同频,充分展现出在室内、室外、商业场景、工业厂区等多类场景下的超强适用性,场景覆盖广度与核心产品市占率均稳居行业第一。得益于朗毅行业头部的技术实力,央视对朗毅机器人进行了多次关注和报道。
唯一全自主跑步亮相机器人运动会队伍:在备受瞩目的首届世界机器人运动会上,朗毅机器人作为开幕式上唯一全自主跑步的队伍闪耀亮相。依托纯视觉端到端导航方案,朗毅机器人全自主完成100米、400米、1500米竞赛项目,最终在1500米决赛中斩获团队第三名。这一成绩,充分展现出朗毅灵睛智能感知导航系统业界领先且经得起严苛现场考验的核心实力。
场景规模化落地,从“单点验证”到“多行业复制”依托技术升级与资本加持,朗毅机器人的具身导航模组——灵睛智能感知导航系统已从“工业试点”走向“多行业规模化落地”,覆盖四大核心场景,成为人形机器人厂商的“标配选择”。
工业制造场景:朗毅具身导航模组已部署于多家工厂和仓储基地,支持机器人24小时连续自主导航与搬运作业,大幅提升生产效率与自动化水平。
安防巡检场景:配备具身导航模组的人形机器人依托高通过性和空间交互能力,已在高价值、高风险环境完成安全巡检与操作任务,有效减少人力安全隐患,降低安全成本。
公共服务场景:在多个景区、商场与展览厅投入使用的人形机器人,凭借流畅的导航与环境交互,承担导览讲解及引导服务,提升了公共服务的交互性、客户体验和效率。
家庭陪护方向:朗毅正积极探索智能陪护机器人市场,致力于构建家庭及养老场景的关键基座设施,推动机器人规模化进入千家万户。
95后博士团队掌舵,以“导航大脑”推动人形机器人成为“通用劳动力”朗毅的快速成长,离不开一支“懂技术、懂产业、有信仰”的核心团队。公司核心成员均毕业于华中科技大学、香港理工大学、电子科技大学、浙江大学等知名高校,研发人员比例高达75%,累计拥有十年以上空间智能算法与机器人产品开发经验;工程团队更有华为、大疆等企业背景,具备从算法到量产的快速转化能力。
创始人兼CEO杨鸿城(华中科技大学人工智能博士)表示:“人形机器人的终极使命是成为‘通用劳动力’,而空间智能就是实现这一目标的‘神经中枢’。半年两轮融资不是终点,而是新起点——我们将继续聚焦‘场景→数据→模型’技术闭环,让机器人不仅能‘自主走’,还能‘懂场景、会沟通、会做事’,真正成为人类的得力伙伴。”
未来,朗毅机器人将以具身导航技术为核心,深化与产业链上下游的合作,持续推动人形机器人在更多场景实现规模化落地,为具身智能时代筑牢“导航基础设施”,让每一台机器人都能畅行世界。
查看更多项目信息,请前往「睿兽分析」。
薪酬方案裁决落地 埃隆・马斯克成全球首位身家超7000亿美元人士
油电共存、小车称王、比亚迪退守,2025 卖得最好的 20 辆车,和你想的不一样
![]()
如果要为 2025 年的车市选一个关键词,那么「重塑」或许最为贴切。
新能源渗透率已越过高速增长拐点,市场进入结构性调整阶段,不再是所有玩家都能分一杯羹的增量时代,而是优胜劣汰的存量竞争。无论车企宣传多么天花乱坠,最终都要靠销量兑现。
董车会统计了 2023 年、2024 年及 2025 年前 11 个月销量超过 18 万辆的车型,发现三年间榜单变动剧烈,有品牌快速崛起,也有曾经的头部选手掉出前列。这背后,是产品力、定价策略、渠道效率乃至组织反应速度的全面比拼。
![]()
▲ 2025 年 1-11 月各车型销量排名
而这场重塑中最引人注目的信号,莫过于来到榜首位置的,不是特斯拉也不是比亚迪,而是一辆强势崛起的小车。
失去榜首的比亚迪
过去两年,国内新能源汽车销量榜首之争主要在特斯拉 Model Y 与比亚迪秦 PLUS 之间展开。Model Y 凭借其全球统一的产品力与品牌号召力,稳居第一梯队——2023 年售出 45.6 万辆,2024 年进一步提升至 48 万辆;而比亚迪则以「车海战术」全面出击,旗下秦、宋、元、海豚、海鸥等多款车型齐头并进,在 2024 年巅峰时期一举包揽销量 Top 10 中的五个席位,几乎占据半壁江山。
![]()
然而,进入 2025 年,格局骤变。吉利星愿以 44.6 万辆的成绩空降榜首,星越 L 与博越 L 也稳居榜单前列。作为老牌自主车企,吉利在经历转型阵痛后,凭借高性价比的小型与紧凑型产品成功反攻,一举打破了比亚迪此前近乎垄断的市场地位。
曾长期称霸该细分市场的海鸥与海豚,在 2023–2024 年合计贡献了可观销量,但到了 2025 年,二者双双失守。
海鸥销量跌至 34.1 万辆,排名滑落至第五;海豚更是一路下滑至第 32 位。与此同时,吉利星愿以更大空间、更精致的设计以及更具竞争力的配置,在同价位区间精准狙击了这两款比亚迪「走量利器」。
![]()
▲ 比亚迪海鸥
雪上加霜的是,曾经的旗舰轿车汉,也悄然消失在销量榜单之中。
2024 年尚能卖出 22.8 万辆的汉,2025 年销量几近腰斩,仅录得 13.7 万辆。面对小米 SU7(27.2 万辆)、特斯拉 Model 3(19.3 万辆)以及极氪、领克等新老对手的围剿,老款「汉」的产品力已显疲态;而改款后的新车型,无论在设计语言还是核心体验上,都未能赢得市场广泛认可,销量表现可谓惨淡。
海鸥
但也有好消息。
秦 L(第 8 名,27.8 万辆)和海豹 06(第 14 名,23 万辆)这两款更新的车型成功完成了迭代,稳固住了其在 10-15 万级轿车市场的基本盘。
![]()
▲比亚迪海豹 06 DM—i
旗下高端品牌腾势 D9 和方程豹钛 7 也表现出了不错的爆款潜力。
明年的比亚迪,如何在前后夹击的态势下稳住份额,相当值得期待。
吉利,成功渡劫
在一众合资品牌及传统自主品牌(如长城、长安、上汽)被比亚迪打得节节败退之际,吉利出手了。
此前两年,吉利在新能源销量榜上表现平平。然而到了 2025 年,局面彻底改写,吉利不仅一举夺得年度销冠,其星越 L 与博越 L 更强势稳居榜单前列,彰显出在紧凑型 SUV 领域的全面统治力。
![]()
▲ 销冠星愿
吉利已成为目前唯一一家在燃油车与新能源两大赛道均跑赢行业大盘的传统车企。
尽管在新能源转型上起步较晚,且早期一度犹豫不决,但吉利的转身不可谓不坚决。早在 2015 年,吉利便提出「蓝色行动」战略,雄心勃勃地设下到 2020 年实现新能源车型销量占比达 90% 的目标。
然而到 2020 年之时,吉利实际的新能源车销量占比却只有 5.2%。
真正促使吉利下定决心的,是比亚迪与理想等新势力在混动与增程赛道上的爆发式成功。自此,吉利果断修订原战略,推出升级版「蓝色吉利行动计划」,全面押注节能技术(涵盖燃油、混动、增程)与智能纯电双线并进。
之后比亚迪的每个爆款吉利几乎都做了对标,银河 L6 对标秦 PLUS DM-i,银河 E8 对标汉 EV,银河 E5 对标元家族,星舰 7 则瞄准了宋 Pro DM-i,星愿则对标海豚和海豹两款小车。
![]()
▲ 吉利银河 E8
产品矩阵上如此,技术竞争上同样如此。
2024 年 5 月,比亚迪发布第五代 DM-i 技术,以 46.06% 的量产发动机热效率刷新纪录;仅半年后,吉利便在银河星舰 7 上搭载全新 EM-i 雷神混动系统,以 46.5% 的热效率反超对手,重夺技术制高点。
今年 2 月,比亚迪宣布旗下 21 款车型全部搭载「天神之眼」辅助驾驶系统,仅在 1 个月之后,吉利就宣布银河系列的后续车型都将搭载「千里浩瀚」不同层级的辅助驾驶方案。
![]()
吉利的策略简单而高效,配置多一点、设计好一点、价格再低一点,期望用用极致的性价比与快速迭代能力,逐个击破比亚迪的主力车型。
星愿登顶销冠,或许只是吉利全面反攻的序章。
消失的埃安
对比三年的销量,有一个品牌的变化非常明显:广汽埃安。
2023 年,埃安尚处在高光时刻。AION Y 以 23.5 万辆的成绩位列第 12 名,AION S 也以 22 万辆紧随其后(第 15 名),两款车型共同撑起了品牌在主流市场的存在感。然而到了 2024 和 2025 年,这两款曾经的主力车型却彻底从销量榜上消失,再无踪影。
![]()
除了车型老化的问题外,背后更多折射出来的是 10-15 万级纯电市场的逻辑变了。2023 年,该细分市场仍由大量 B 端需求(尤其是网约车)托底;而进入 2024–2025 年后,随着比亚迪、吉利银河等兼具设计感、智能化与家庭属性的新车型密集入场,那些缺乏 C 端吸引力、仅具「工具属性」的纯电动车迅速被边缘化。
数据印证了这一趋势。据乘联会统计,2023 年全国用于出租及网约车的新车销量达 85 万辆,其中埃安贡献约 22 万辆,占其全年总销量的 45%,也占当年网约车新增总量的近四分之一。然而,随着网约车市场快速饱和,B 端订单锐减,埃安失去了最重要的销量支柱,市场表现随之急转直下。
![]()
▲ 2025 年 11 月埃安各车型销量
面对困局,埃安曾试图通过向上突破来寻找出路。他们推出了高端品牌「昊铂」,陆续布局了昊铂 GT、SSR、HT 等车型。
但高端品牌的建设本就依赖长期技术积累、用户信任与体系化运营,而彼时的埃安显然准备不足。结果,昊铂系列多数月份销量仅百辆上下,市场反响平平,不仅未能打开新局面,反而分散了本应用于主品牌的资源,导致埃安在 15 万元左右的核心价位段产品力停滞不前,尤其在智能化配置上明显落后于竞品。
所幸,埃安在今年终于意识到战略偏差。随着昊铂品牌正式独立运营,埃安得以重新聚焦主品牌,将研发、产品、渠道与营销资源全面回调。业内消息显示,公司内部已启动一轮深度调整,从组织架构到产品定义,从技术路线到用户运营,均在进行系统性「换血」。
最近几款新车的产品力都很能打,如 9 月份推出的埃安 RT 就以 9.98 万元起的亲民价格提供了十分越级的体验。市场反馈也比以往热烈了不少。
但究竟效果如何,只能明年再看了。
![]()
燃油车最后的堡垒
我们一直以来通常认为燃油车在溃败,表面上看,电动化浪潮席卷一切,但细看销量数据,会发现一个反直觉的现象,2023 年,两款车型全年销量分别约为 19 万辆和 18.9 万辆;而到了 2025 年前 11 个月,帕萨特已售出 23.8 万辆,迈腾也达到 20.2 万辆。是榜单上极少数还能维持 20 万+年销量的合资 B 级车。
![]()
▲ 上汽大众帕萨特近一年销量走势 数据来源:车主之家
大众精准捕捉到了那些对新技术持谨慎态度、更看重可靠性与使用确定性的「保守派」用户。
面对价格战与电动化的双重压力,上汽大众和一汽-大众选择了一条务实路径——大幅降价、配置拉满、强化信任。
帕萨特部分车型终端售价已下探至 13 万元区间,IQ.Drive 智驾系统、自动泊车、全景影像等以往只属于高配车型的配置,也开始下放到中低配车型上。
![]()
整个合资阵营虽在新能源冲击下整体承压,却并未全面崩盘,而是退守到自己最擅长的细分市场。
日系的轩逸、RAV4 荣放和凯美瑞的销量确实较巅峰时期有所下滑,但依然稳居各自细分榜单前列。
这些车型的共同点在于,它们早已完成产品心智的沉淀,即便在智能化和加速性能上落后于新势力,它们在油耗、保值率、维修便利性和长期使用成本上的优势,依然对三四线城市用户家庭第二辆车等特定群体构成强大吸引力。尤其是在充电基础设施尚未完全覆盖的区域,燃油车仍是无可替代的实用工具。
![]()
▲一汽丰田 RAV4
市场的真相或许比「电替代油」的简单叙事复杂得多。未来几年,中国汽车市场大概率不会走向单一技术路线的垄断,而是进入一个油电长期共存、各取所需的多元阶段——电动化是方向,但燃油车仍有活路。
特斯拉 Model Y,铁杆盘稳固,但已到天花板
回顾特斯拉 Model Y 在中国市场近三年的销量,始终在 45 万至 48 万辆的区间内震荡。即便面对问界 M7、理想 L6 L7、小米 YU7 等强劲新势力车型的轮番冲击,甚至在价格战愈演愈烈的背景下,Model Y 的销量曲线依然异常平直——既未大幅下滑,也未显著上扬。
Model Y 在中国似乎已经形成一个高度稳定的「铁杆用户群」。这部分消费者对品牌高度认同,对产品性能、智能化体验或特斯拉生态有强烈偏好,其购买决策几乎不受外部竞争或短期促销影响。无论市场如何喧嚣,他们始终是 Model Y 最可靠的销量基石。![]()
▲特斯拉 Model Y 近一年销量走势 数据来源:车主之家
然而,这种稳定性或许也说明 Model Y 的增长已经到达了天花板。
自 2021 年国产以来,Model Y 在核心设计、三电系统和智能座舱架构多年未有颠覆性更新。尽管特斯拉通过软件迭代和推出特供车型来维持竞争力,但增量空间极其有限。
正因如此即便 Model Y 本身并未「变弱」,它也在 2025 年让出了年度销冠的位置。取而代之的是星愿这类主打高性价比、精准切入大众市场的车型。
Model Y 的销量波动,或许也是中国新能源产品不断演进的缩影。特斯拉已经完成了「鲶鱼」的历史使命,若无下一代产品或重大技术突破,Model Y 很可能长期徘徊在 45–50 万辆/年的「稳态区间」,成为一座坚固但不再扩张的孤岛。
![]()
▲ 特斯拉 Model YL 座舱内部
绕不开的小米 SU7
小米 SU7 和特斯拉 Model Y 是销量榜前十中唯二的平均售价在 20 万元以上高端车型。
曾经中国家庭用户的首选永远是 SUV,在 SU7 出现之前,纯电轿车被认为是一个上限不高的细分市场。
如蔚来 ET5、小鹏 P7 等车型都曾在细分市场都遇到了「月销 1 万」的的隐形墙,但小米 SU7 把年销量做到了 27 万辆,月均 2 万以上,证明了纯电轿车可以不仅是「代步工具」,更可以成为像 iPhone 一样的「科技时尚单品」。
![]()
另一个有象征意义的数据是,小米 SU7 今年的总销量超越了特斯拉 Model 3(19.3 万辆)。
Model 3 上市多年,虽然经历了改款,但在中国消费者眼中,它已经越来越像一个「标准化的纯电车」。它很强,但缺乏新鲜感和情绪价值。
而小米 SU7 在 Model 3 建立的「极简+操控」的基础上,做了两件 Model 3 做不到的事——「互联互通」和「配置堆料」。
中国消费者虽然认可特斯拉的品牌,但如果能用同样甚至更低的价格,买到更大的空间、更好的内饰、更本土化的智能生态,他们会毫不犹豫地倒戈。
![]()
小米 SU7 实际上是吃掉了 Model 3 增长停滞后的溢出份额,并抢夺了那些原本还在犹豫是否购买特斯拉的摇摆用户。
过去年轻人的第一台「体面车」通常是 34C,而现在,在马力变得廉价之后,开一辆「科技属性」更强的小米,比开一辆丐版「宝马 3 系似乎更能代表」现代生活方式。
问界,突破 BBA 的护城河
如果把榜单往后再翻几页,我们还能看到另一个有意思的现象。
在 40 万元以上,且年销能超过 10 万辆的高端豪华车市场,能和 BBA 掰手腕的国产品牌,依旧只有问界。
如果单看 SUV 车型,问界 M8 以 13.3 万辆的成绩超越了奥迪 Q5L 的 11.9 万辆,是过去一年卖得最好的豪华 SUV。
![]()
要理解问界的上位,首先要理解 BBA 等传统豪车护城河的消解。
在燃油车时代,BBA 的溢价逻辑是可感知的物理豪华感,V6/V8 发动机的轰鸣、毫秒级换挡的变速箱,底盘调教的厚重感,这些都是极高门槛的技术壁垒。
消费者为此买单,买的是这一套复杂的机械艺术品,以及随之而来的社会地位。那时候的「豪华」,是静态的展示,无论你开不开它,那个立在车头的 Logo 和车内的真皮实木都在彰显价值。
![]()
然而,电动化时代带来了一场残酷的「机械平权」。电机轻易地让 20 几万的车型拥有了过去百万级豪车的加速体验;空气悬架和 CDC 减震器的供应链下放,让底盘质感的差异被无限缩小。
当原本的稀缺资源变得廉价,传统豪车就出现了「价值真空」。 这正是问界切入的时刻,它没有在旧赛道上过多纠缠,而是通过智能化,建立了一套新的价值坐标系。
在旧时代,车是冷冰冰的工具,人必须去适应车,而在问界构建的体系里,车更像是一个有感知能力的智能终端。
![]()
鸿蒙座舱让车机像手机一样省心顺手,不需要你去适应机器,而乾崑智驾把安全从「耐撞」升维成了「避险」,能在关键时刻帮你踩停、替你挡灾,这种实实在在的「保命」能力,才是科技时代最高级的溢价。
系统能力之争
如果把这些品牌和车型放在一张更大的时间轴上看,会发现一个越来越清晰的事实:中国汽车市场已经从「技术路线之争」,进入了「系统能力之争」。
过去几年,电动化是唯一的主线,谁能更快「上电」,谁就能拿到红利。但当新能源渗透率越过拐点,技术不再是稀缺品,真正拉开差距的,开始变成三件事:对用户的理解深度、产品迭代的速度,以及组织执行的确定性。
比亚迪今年的转变是最好的例子。
比亚迪的下滑很大程度上在于它太早成功,也太早暴露了边界。当所有对手都学会用更低的价格、更精细的定位、更快的反应去拆解它的优势时,比亚迪需要重新回答一个问题:除了性价比,它还能用什么继续扩大用户池?
他们花了一整年找到的答案是「用户价值」。
他们为多款车型都增加了配置更高,价格更低的车型。升级点都集中在用户最关注的续航和舒适性的部分,像联动底盘、动力、座舱三大系统的定眩智能防晕车功能、与生态伙伴共同定制的宠物座椅和安全座椅配件都能让用户感知更强,用车舒适度更高。
简单点说就是尝试将技术转化为用户实实在在的体验。
![]()
市场营销学中有个经典的 4P 理论。
产品(Product)、价格(Price)、营销(Promotion)、渠道(Place)是四个决定销量的关键要素。
今天的中国车市,奖励的就是两点以上的长期稳定输出。
能把产品和价格同时做到极致的,才能吃下最大规模;能把产品和营销高度耦合的,才能制造现象级爆款;而三点、四点同时成立的玩家,才有资格谈「长期统治」。
未来几年,中国汽车市场不会有单一赢家,但一定会不断淘汰那些,只靠运气和红利活着的玩家。
#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。
我国成功发射通信技术试验卫星二十三号
电影《阿凡达3 》上映3天,总票房破3亿
具身智能老炮再获数亿融资,移动多臂机器人已批量工业落地|36氪首发
作者丨欧雪
编辑丨袁斯来
硬氪获悉,具身智能机器人公司飒智智能近期连续完成A++轮及A+++轮融资,累计金额达数亿元。我们总结了最新两轮融资信息和该公司几大亮点:
![]()
融资金额及领投机构
融资轮次:A++轮及A+++轮
融资金额:数亿元
投资方:海通开元和国元直投
资金用途:40%用于技术研发,30%用于海外市场拓展,15-20%用于智能制造产线建设,剩余用于流动资金补充
![]()
公司基本信息
成立时间:2018年
公司总部:上海
核心产品:单臂、双臂、四臂机器人;AMR移动机器人;
技术亮点:飒智智能自主研发的SAGE-OS机器人操作系统与一体化控制器SAGE-Brain,实现了从感知、决策、规划到控制的低延迟实时闭环,从根本上解决了传统工业自动化在柔性化、智能化方面的瓶颈。
在自主作业方面,公司构建了“本体智能-群体智能-规模化智能”三层技术体系,通过融合视觉、力学、语音等多模态感知实现复杂工件的识别与定位,并借助自研的VLAS大模型、底层运动控制算法与多臂协同规划,在动态工业环境下完成毫米级(±0.05mm)精度的装配与检测作业。
应用场景:在高端离散制造领域,可用于汽车零部件(如线束检测、精密装配、电驱制造、热交换器加工)、3C电子(如SMT飞达上下料、主板检测)、生物制药(无菌分拣、实验室自动化)等行业的柔性产线与智能化升级。
公司当前以汽车、电子等行业的头部制造企业为突破口,通过“抓龙头、立标杆、拓渠道”的策略实现规模化复制,并同步向新能源、高端食品药品等更广阔的工业场景拓展。
![]()
飒智智能四臂机器人(图源/企业)
![]()
市场体量
工业机器人市场正处于从“固定自动化”向“移动智能化”转型的关键阶段。从第三方行业报告看到,当前全球工业机器人市场规模已达千亿级,但传统机器人主要服务于仅占生产环节约30%的连续结构化场景。而占制造业70%的离散制造场景因对柔性化、智能化的高要求,自动化渗透率仍处于低位,构成了巨大的存量替代空间。
随着新能源汽车、高端电子等行业加速向“小批量、多品种”的柔性制造模式转型,对能够自主移动、多空间作业的智能机器人需求持续爆发。
![]()
公司业绩
飒智智能已实现多年盈利,近年来营收保持50%-120%的年增速,明年预期增长将达300%。公司当前年出货量约千台,产能正积极扩建,目标提升至5000台年产能。目前,公司已成功为理想汽车、强生、三菱等头部客户打造项目,并带动了广泛的中腰部客户拓展,形成了可复制的规模化增长路径。
![]()
团队背景
公司创始人兼CEO张建政为上海交通大学机器人方向博士,拥有21年研发经验,曾任职于全球工业机器人巨头发那科(FANUC),主导开发了国内首套应用于汽车行业的3D视觉手眼协调系统。团队其他核心成员也多曾在罗克韦尔自动化、ABB等自动化和机器人企业担任关键技术职务,平均从业年限超过15年。
![]()
创始人思考
硬氪:与传统的工业机器人相比,飒智的差异化优势在哪里?
张建政:传统机器人是固定式作业,适合连续生产,比如汽车焊接、喷涂。但现在新势力造车是混线生产,工艺每年都在迭代变化。况且整个制造业中离散制造占70%,需要多批量小品种的柔性作业,传统方式做不了。
我们判断,机器人要完全替代人,必须解决移动作业和多元多空间作业的问题。不是在固定地方干活,而是像人一样在智能化工厂的多个空间里干不同的事。这需要在开放、非结构化的场景下,自主辨识场景、规划路径、感知作业变化并调整作业效果。所以,我们的机会是用具备移动作业能力的具身智能机器人,在离散制造环节或连续多变环节替换掉固定机器人。本质上,我们和他们不是同一类产品,我们解决的是他们解决不了的问题。
硬氪:目前公司已经实现盈利,这是怎么做到的?
张建政:我们被很多投资者贴过标签——“唯一能盈利的工业智能机器人公司”。实际上,我们多年有净利润,也证明我们的商业模式是成立的。定价上,我们按“代替人工”来算,让客户用1.5到2年的工人工资回收机器人成本。技术上,我们自研操作系统和控制器,硬件不足算法补,模块化设计像搭乐高,成本更低。
硬氪:公司在技术和市场方面接下来有哪些具体的发展规划?
张建政:在技术方面,我们将重点推进“本体智能、群体智能、规模化智能”三大方向。我们计划明年发布新一代具身智能机器人,进一步提升机器人在复杂作业场景中的多任务协同能力。
在市场方面,我们将加快全球布局。目前海外收入占比已超过20%,未来目标提升至50%左右。我们正在加强东南亚、墨西哥、欧洲、中东等地区的本地化团队建设,建立销售、技术支持和服务中心。
![]()
投资人思考
海通开元表示:飒智智能在推进全球战略的同时,以其高超的技术能力和深厚的场景经验,其具身智能机器人已成功进入多家世界巨头客户的供应商体系,验证了其技术方案在真实工业场景中的可靠性与实用性,我们看好其在智能制造这一广阔蓝海中的技术领先优势和工程化落地能力。随着全球制造业向柔性化转型加速,飒智具身智能机器人将成为智能制造的核心生产力之一,为实体经济带来真正的效率革命。
世贸报告:人工智能到2040年或推动全球贸易增长近四成
当「探索」遇上「好奇心」,你的影像会如何思考?
深圳华大北斗科技股份有限公司递表港交所
每日一题-删列造序 II🟡
给定由 n 个字符串组成的数组 strs,其中每个字符串长度相等。
选取一个删除索引序列,对于 strs 中的每个字符串,删除对应每个索引处的字符。
比如,有 strs = ["abcdef", "uvwxyz"],删除索引序列 {0, 2, 3},删除后 strs 为["bef", "vyz"]。
假设,我们选择了一组删除索引 answer,那么在执行删除操作之后,最终得到的数组的元素是按 字典序(strs[0] <= strs[1] <= strs[2] ... <= strs[n - 1])排列的,然后请你返回 answer.length 的最小可能值。
示例 1:
输入:strs = ["ca","bb","ac"] 输出:1 解释: 删除第一列后,strs = ["a", "b", "c"]。 现在 strs 中元素是按字典排列的 (即,strs[0] <= strs[1] <= strs[2])。 我们至少需要进行 1 次删除,因为最初 strs 不是按字典序排列的,所以答案是 1。
示例 2:
输入:strs = ["xc","yb","za"] 输出:0 解释: strs 的列已经是按字典序排列了,所以我们不需要删除任何东西。 注意 strs 的行不需要按字典序排列。 也就是说,strs[0][0] <= strs[0][1] <= ... 不一定成立。
示例 3:
输入:strs = ["zyx","wvu","tsr"] 输出:3 解释: 我们必须删掉每一列。
提示:
n == strs.length1 <= n <= 1001 <= strs[i].length <= 100-
strs[i]由小写英文字母组成
从左到右贪心 + 优化(Python/Java/C++/Go)
例如 $\textit{strs}=[\texttt{ac},\texttt{ad},\texttt{ba},\texttt{bb}]$,竖着看就是
$$
\begin{aligned}
& \texttt{ac} \
& \texttt{ad} \
& \texttt{ba} \
& \texttt{bb} \
\end{aligned}
$$
第一列是升序,可以不删。
- 如果删第一列,那么需要完整地比较第二列的四个字母是不是升序。
- 如果不删第一列,那么对于第二列,由于 $\texttt{d}$ 和 $\texttt{a}$ 前面的字母不同,只看第一列的字母就能确定 $\texttt{ad} < \texttt{ba}$,所以我们不需要比较 $\texttt{d}$ 和 $\texttt{a}$ 的大小。此时第二列分成了两组 $[\texttt{c},\texttt{d}]$ 和 $[\texttt{a},\texttt{b}]$,只需判断组内字母是不是升序,而不是完整地比较第二列的四个字母。
由此可见,当列已经是升序时,不删更好,后面需要比较的字母更少,更容易满足要求,最终删除的列更少。
如果列不是升序,那么一定要删(否则最终得到的数组不是字典序排列)。
优化前
###py
class Solution:
def minDeletionSize(self, strs: List[str]) -> int:
n, m = len(strs), len(strs[0])
a = [''] * n # 最终得到的字符串数组
ans = 0
for j in range(m):
for i in range(n - 1):
if a[i] + strs[i][j] > a[i + 1] + strs[i + 1][j]:
# j 列不是升序,必须删
ans += 1
break
else:
# j 列是升序,不删更好
for i, s in enumerate(strs):
a[i] += s[j]
return ans
###java
class Solution {
public int minDeletionSize(String[] strs) {
int n = strs.length;
int m = strs[0].length();
String[] a = new String[n]; // 最终得到的字符串数组
Arrays.fill(a, "");
int ans = 0;
next:
for (int j = 0; j < m; j++) {
for (int i = 0; i < n - 1; i++) {
if ((a[i] + strs[i].charAt(j)).compareTo(a[i + 1] + strs[i + 1].charAt(j)) > 0) {
// j 列不是升序,必须删
ans++;
continue next;
}
}
// j 列是升序,不删更好
for (int i = 0; i < n; i++) {
a[i] += strs[i].charAt(j);
}
}
return ans;
}
}
###cpp
class Solution {
public:
int minDeletionSize(vector<string>& strs) {
int n = strs.size(), m = strs[0].size();
vector<string> a(n); // 最终得到的字符串数组
int ans = 0;
for (int j = 0; j < m; j++) {
bool del = false;
for (int i = 0; i < n - 1; i++) {
if (a[i] + strs[i][j] > a[i + 1] + strs[i + 1][j]) {
// j 列不是升序,必须删
ans++;
del = true;
break;
}
}
if (!del) {
// j 列是升序,不删更好
for (int i = 0; i < n; i++) {
a[i] += strs[i][j];
}
}
}
return ans;
}
};
###go
func minDeletionSize(strs []string) (ans int) {
n, m := len(strs), len(strs[0])
a := make([]string, n) // 最终得到的字符串数组
next:
for j := range m {
for i := range n - 1 {
if a[i]+string(strs[i][j]) > a[i+1]+string(strs[i+1][j]) {
// j 列不是升序,必须删
ans++
continue next
}
}
// j 列是升序,不删更好
for i, s := range strs {
a[i] += string(s[j])
}
}
return
}
复杂度分析
- 时间复杂度:$\mathcal{O}(nm^2)$,其中 $n$ 是 $\textit{strs}$ 的长度,$m$ 是 $\textit{strs}[i]$ 的长度。比较 $\mathcal{O}(nm)$ 次大小,每次 $\mathcal{O}(m)$。
- 空间复杂度:$\mathcal{O}(nm)$。
优化
回顾前文的例子:
$$
\begin{aligned}
& \texttt{ac} \
& \texttt{ad} \
& \texttt{ba} \
& \texttt{bb} \
\end{aligned}
$$
第一列升序,不删。由于 $\textit{strs}[1][0] < \textit{strs}[2][0]$,后续 $a[1] < a[2]$ 必定成立,所以不需要比较这两个字符串。对于其余相邻字符串来说,由于第一列的字母都一样,所以只需比较第二列的字母,无需比较整个字符串。
怎么维护需要比较的下标(行号)呢?可以用哈希集合,或者布尔数组,或者创建一个下标列表,删除列表中的无需比较的下标。最后一种方法最高效,我们可以用 27. 移除元素 的方法,原地删除无需比较的下标,见 我的题解。
###py
class Solution:
def minDeletionSize(self, strs: List[str]) -> int:
n, m = len(strs), len(strs[0])
check_list = list(range(n - 1))
ans = 0
for j in range(m):
for i in check_list:
if strs[i][j] > strs[i + 1][j]:
# j 列不是升序,必须删
ans += 1
break
else:
# j 列是升序,不删更好
new_size = 0
for i in check_list:
if strs[i][j] == strs[i + 1][j]:
# 相邻字母相等,下一列 i 和 i+1 需要继续比大小
check_list[new_size] = i # 原地覆盖
new_size += 1
del check_list[new_size:]
return ans
###java
class Solution {
public int minDeletionSize(String[] strs) {
int n = strs.length;
int m = strs[0].length();
int size = n - 1;
int[] checkList = new int[size];
for (int i = 0; i < size; i++) {
checkList[i] = i;
}
int ans = 0;
next:
for (int j = 0; j < m; j++) {
for (int t = 0; t < size; t++) {
int i = checkList[t];
if (strs[i].charAt(j) > strs[i + 1].charAt(j)) {
// j 列不是升序,必须删
ans++;
continue next;
}
}
// j 列是升序,不删更好
int newSize = 0;
for (int t = 0; t < size; t++) {
int i = checkList[t];
if (strs[i].charAt(j) == strs[i + 1].charAt(j)) {
// 相邻字母相等,下一列 i 和 i+1 需要继续比大小
checkList[newSize++] = i; // 原地覆盖
}
}
size = newSize;
}
return ans;
}
}
###cpp
class Solution {
public:
int minDeletionSize(vector<string>& strs) {
int n = strs.size(), m = strs[0].size();
vector<int> check_list(n - 1);
ranges::iota(check_list, 0);
int ans = 0;
for (int j = 0; j < m; j++) {
bool del = false;
for (int i : check_list) {
if (strs[i][j] > strs[i + 1][j]) {
// j 列不是升序,必须删
ans++;
del = true;
break;
}
}
if (del) {
continue;
}
// j 列是升序,不删更好
int new_size = 0;
for (int i : check_list) {
if (strs[i][j] == strs[i + 1][j]) {
// 相邻字母相等,下一列 i 和 i+1 需要继续比大小
check_list[new_size++] = i; // 原地覆盖
}
}
check_list.resize(new_size);
}
return ans;
}
};
###go
func minDeletionSize(strs []string) (ans int) {
n, m := len(strs), len(strs[0])
checkList := make([]int, n-1)
for i := range checkList {
checkList[i] = i
}
next:
for j := range m {
for _, i := range checkList {
if strs[i][j] > strs[i+1][j] {
// j 列不是升序,必须删
ans++
continue next
}
}
// j 列是升序,不删更好
newCheckList := checkList[:0] // 原地
for _, i := range checkList {
if strs[i][j] == strs[i+1][j] {
// 相邻字母相等,下一列 i 和 i+1 需要继续比大小
newCheckList = append(newCheckList, i)
}
}
checkList = newCheckList
}
return
}
复杂度分析
- 时间复杂度:$\mathcal{O}(nm)$,其中 $n$ 是 $\textit{strs}$ 的长度,$m$ 是 $\textit{strs}[i]$ 的长度。
- 空间复杂度:$\mathcal{O}(n)$。
专题训练
见下面贪心题单的「§1.4 从最左/最右开始贪心」。
分类题单
- 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
- 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
- 单调栈(基础/矩形面积/贡献法/最小字典序)
- 网格图(DFS/BFS/综合应用)
- 位运算(基础/性质/拆位/试填/恒等式/思维)
- 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
- 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
- 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
- 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
- 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
- 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
- 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)
欢迎关注 B站@灵茶山艾府
删列造序
贪心的想法,我们从前往后的比较每一个字符,如果发现A[i][j]<A[i-1][j],那么就意味着这一列是必须删除的,但是这样做有一个问题:就是如果之前我们已经确定了这两列的大小关系,那么这个时候即使小于,也不用删除,比如acd,adc,对于这两个字符串来说,当我们比较前面两个字符时就已经确定大小关系了,那么当比较第三个字符时,即使d>c,也是不需要删除的。
所以针对多个字符串,我们开一个数组vis[n],vis[i]表示第i行和第i-1行是否已经确定大小关系了。
思路:
从第0列开始比较,如果发现这一列中有不确定大小关系并且是小于前面行的,就意味着是必须删除的,这个时候就不需要更新vis,否则我们就跟新vis;
###java
class Solution {
public int minDeletionSize(String[] A) {
int n = A.length;
int m = A[0].length();
int[] vis = new int[n];
int ans = 0;
for (int i = 0; i < m; i++) {
boolean isDelete=false;
for (int j = 1; j < n; j++) {
if(vis[j]==1)continue;
if(A[j].charAt(i)<A[j-1].charAt(i)){
isDelete = true;
break;
}
}
if(isDelete)ans++;
else{
for (int j = 1; j < n; j++) {
if(A[j].charAt(i)>A[j-1].charAt(i)){
vis[j]=1;
}
}
}
}
return ans;
}
}
删列造序 II
方法 1:贪心
想法
针对该问题,我们考虑保留哪些列去获得最终的有序结果,而不是删除哪些列。
如果第一列不是字典序排列的,我们就必须删除它。
否则,我们需要讨论是否保留第一列。会出现以下两种情况:
-
如果我们不保留第一列,则最后答案的行需要保证有序;
-
如果我们保留了第一列,那么最终答案的行(除去第一列)只需要在第一个字母相同的情况下需要保证有序。
这个描述很难理解,看下面的例子:
假设我们有
A = ["axx", "ayy", "baa", "bbb", "bcc"],当我们保留第一列之后,最终行变成R = ["xx", "yy", "aa", "bb", "cc"],对于这些行,并不要求所有有序(R[0] <= R[1] <= R[2] <= R[3] <= R[4]),只需要达到一个较弱的要求:对于第一个字母相同的行保证有序(R[0] <= R[1]和R[2] <= R[3] <= R[4])。
现在,我们只将结论应用到第一列,但实际上这个结论对每列都合适。如果我们不能取用第一列,就删除它。否则,我们就取用第一列,因为无论如何都可以使要求更简单。
算法
首先没有任意列保留,对于每一列:如果保留后结果保持有序,就保留这一列;否则删除它。
###Java
class Solution {
public int minDeletionSize(String[] A) {
int N = A.length;
int W = A[0].length();
int ans = 0;
// cur : all rows we have written
// For example, with A = ["abc","def","ghi"] we might have
// cur = ["ab", "de", "gh"].
String[] cur = new String[N];
for (int j = 0; j < W; ++j) {
// cur2 : What we potentially can write, including the
// newest column col = [A[i][j] for i]
// Eg. if cur = ["ab","de","gh"] and col = ("c","f","i"),
// then cur2 = ["abc","def","ghi"].
String[] cur2 = Arrays.copyOf(cur, N);
for (int i = 0; i < N; ++i)
cur2[i] += A[i].charAt(j);
if (isSorted(cur2))
cur = cur2;
else
ans++;
}
return ans;
}
public boolean isSorted(String[] A) {
for (int i = 0; i < A.length - 1; ++i)
if (A[i].compareTo(A[i+1]) > 0)
return false;
return true;
}
}
###Python
class Solution(object):
def minDeletionSize(self, A):
def is_sorted(A):
return all(A[i] <= A[i+1] for i in xrange(len(A) - 1))
ans = 0
# cur : all rows we have written
# For example, with A = ["abc","def","ghi"] we might have
# cur = ["ab", "de", "gh"].
cur = [""] * len(A)
for col in zip(*A):
# cur2 : What we potentially can write, including the
# newest column 'col'.
# Eg. if cur = ["ab","de","gh"] and col = ("c","f","i"),
# then cur2 = ["abc","def","ghi"].
cur2 = cur[:]
for i, letter in enumerate(col):
cur2[i] = cur2[i] + letter
if is_sorted(cur2):
cur = cur2
else:
ans += 1
return ans
复杂度分析
- 时间复杂度:$O(NW^2)$,其中 $N$ 是
A的长度,$W$ 是A[i]的长度。 - 空间复杂度:$O(NW)$。
方法 2:优化贪心
解释
方法 1 可以用更少的空间和时间。
核心思路是记录每一列的”割“信息。在第一个例子中,A = ["axx","ayy","baa","bbb","bcc"](R 也是相同的定义),第一列将条件 R[0] <= R[1] <= R[2] <= R[3] <= R[4] 切成了 R[0] <= R[1] 和 R[2] <= R[3] <= R[4]。也就是说,"a" == column[1] != column[2] == "b" ”切割“了 R 中的一个条件。
从更高层面上说,我们的算法只需要考虑新加进的列是否保证有序。通过维护”割“的信息,只需要比较新列的字符。
###Java
class Solution {
public int minDeletionSize(String[] A) {
int N = A.length;
int W = A[0].length();
// cuts[j] is true : we don't need to check any new A[i][j] <= A[i][j+1]
boolean[] cuts = new boolean[N-1];
int ans = 0;
search: for (int j = 0; j < W; ++j) {
// Evaluate whether we can keep this column
for (int i = 0; i < N-1; ++i)
if (!cuts[i] && A[i].charAt(j) > A[i+1].charAt(j)) {
// Can't keep the column - delete and continue
ans++;
continue search;
}
// Update 'cuts' information
for (int i = 0; i < N-1; ++i)
if (A[i].charAt(j) < A[i+1].charAt(j))
cuts[i] = true;
}
return ans;
}
}
###Python
class Solution(object):
def minDeletionSize(self, A):
# cuts[i] is True : we don't need to check col[i] <= col[i+1]
cuts = [False] * (len(A) - 1)
ans = 0
for col in zip(*A):
if all(cuts[i] or col[i] <= col[i+1] for i in xrange(len(col) - 1)):
for i in xrange(len(col) - 1):
if col[i] < col[i+1]:
cuts[i] = True
else:
ans += 1
return ans
复杂度分析
- 时间复杂度:$O(NW)$,其中 $N$ 是
A的长度,$W$ 是A[i]的长度。 - 空间复杂度:额外空间开销 $O(N)$(在 Python 中,
zip(*A)需要 $O(NW)$ 的空间)。
React Hooks 深度理解:useState / useEffect 如何管理副作用与内存
🤯你以为 React Hooks 只是语法糖?
不——它们是在帮你对抗「副作用」和「内存泄漏」
如果你只把 Hooks 当成“不用 class 了”,
那你可能只理解了 React 的 10%。
🚀 一、一个“看起来毫无问题”的组件
我们先从一个你我都写过无数次的组件开始:
function App() {
const [num, setNum] = useState(0)
return (
<div onClick={() => setNum(num + 1)}>
{num}
</div>
)
}
看起来非常完美:
- ✅ 没有 class
- ✅ 没有 this
- ✅ 就是一个普通函数
但问题是:
React 为什么要发明 Hooks?
useState / useEffect 到底解决了什么“本质问题”?
答案其实只有一个关键词👇
💣 二、React 世界的终极敌人:副作用(Side Effect)
React 背后有一个很少被明说,但极其重要的信仰:
组件 ≈ 纯函数
🧠 什么是纯函数?
- 相同输入 → 永远相同输出
- 不依赖外部变量
- 不产生额外影响(I/O、定时器、请求)
function add(a, b) {
return a + b
}
而理想中的 React 组件是:
(props + state) → JSX
React 希望你“只负责算 UI”,
而不是在渲染时干别的事。
⚠️ 但现实是:你必须干“坏事”
真实业务中,你不可避免要做这些事:
- 🌐 请求接口
- ⏱️ 设置定时器
- 🎧 事件监听
- 📦 订阅 / 取消订阅
- 🧱 操作 DOM
这些行为有一个共同点👇
❌ 它们都不是纯函数行为
✅ 它们都是副作用
如果你直接把副作用写进组件函数,会发生什么?
function App() {
fetch('/api/data') // ❌
return <div />
}
👉 每一次 render 都请求
👉 状态更新 → 再 render → 再请求
👉 组件直接失控
🧯 三、useEffect:副作用的“隔离区”
useEffect 的存在,本质只干一件事:
把副作用从“渲染阶段”挪走
useEffect(() => {
// 副作用逻辑
}, [])
💡 一句话理解:
render 阶段必须纯,
effect 阶段允许脏。
📦 四、依赖数组不是细节,而是“副作用边界”
1️⃣ 只执行一次(挂载)
useEffect(() => {
console.log('mounted')
}, [])
- 只在组件挂载时执行
- 类似 Vue 的
onMounted
2️⃣ 依赖变化才执行
useEffect(() => {
console.log(num)
}, [num])
-
num变化 → 执行 - 不变 → 不执行
依赖数组的本质是:
“这个副作用依赖谁?”
3️⃣ 不写依赖项?
useEffect(() => {
console.log('every render')
})
👉 每次 render 都执行
👉 99% 的时候是性能陷阱
💥 五、90% 新手都会踩的坑:内存泄漏
来看一个极其经典的 Hooks 错误写法👇
useEffect(() => {
const timer = setInterval(() => {
console.log(num)
}, 1000)
}, [num])
你觉得这段代码有问题吗?
有,而且非常致命。
❌ 问题在哪里?
-
num每变一次 - effect 重新执行
- 新建一个定时器
- ❗旧定时器还活着
结果就是:
- ⏱️ 定时器越来越多
- 📈 内存持续上涨
- 💥 控制台疯狂打印
- 🧠 内存泄漏
🧹 六、useEffect return:副作用的“善终机制”
React 给你准备了一个官方清理通道👇
useEffect(() => {
const timer = setInterval(() => {
console.log(num)
}, 1000)
return () => {
clearInterval(timer)
}
}, [num])
⚠️ 重点来了
return 的函数不是“卸载时才执行”
而是:
下一次 effect 执行前,一定会先执行它
React 内部顺序是这样的:
- 执行上一次 effect 的 cleanup
- 再执行新的 effect
👉 这就是 Hooks 防内存泄漏的核心设计
🧠 七、useState:为什么初始化不能异步?
你在学习 Hooks 时,一定问过这个问题👇
❓ 我能不能在 useState 初始化时请求接口?
useState(async () => {
const data = await fetchData()
return data
})
答案很干脆:
❌ 不行
🤔 为什么不行?
因为 React 必须保证:
- 首次 render 立即有确定的 state
- 异步结果是不确定的
- state 一旦初始化,必须是同步值
React 允许的只有这种👇
useState(() => {
const a = 1 + 2
const b = 2 + 3
return a + b
})
💡 这叫 惰性初始化
💡 但前提是:同步 + 纯函数
🌐 八、那异步请求到底该写哪?
答案只有一个地方:
useEffect
useEffect(() => {
async function query() {
const data = await queryData()
setNum(data)
}
query()
}, [])
🎯 这是 React 官方推荐模式
- state 初始化 → 确定
- 异步请求 → 副作用
- 数据回来 → 更新状态
🔄 九、为什么 setState 可以传函数?
setNum(prev => prev + 1)
这不是“花里胡哨”,而是并发安全设计。
React 内部可能会:
- 合并多次更新
- 延迟执行 setState
如果你直接用 num + 1,很可能拿到的是旧值。
函数式 setState = 永远安全
🏁 十、Hooks 的真正价值(总结)
如果你只把 Hooks 当成:
“不用写 class 了”
那你只看到了表面。
Hooks 真正解决的是:
- 🧩 状态如何在函数中稳定存在
- 🧯 副作用如何被精确控制
- 🧠 生命周期如何显式建模
- 🔒 内存泄漏如何被主动规避
✨ 最后的掘金金句
useState 解决的是:数据如何“活着”
useEffect 解决的是:副作用如何“善终”React Hooks 不只是语法升级,
而是一场从“命令式生命周期”
到“声明式副作用管理”的革命。
AI Agent 介绍
前言
这周在组内做了一次关于 Agent 设计模式 的分享,主要介绍和讲解了 ReAct 模式 与 P&A(Plan and Execute)模式。我计划将这次分享拆分为三篇文章,对我在组会中讲解的内容进行更加系统和细致的整理。
在正式进入具体的 Agent 模式实现之前,有一个绕不开的问题需要先回答清楚:
什么是 AI Agent?它解决了什么问题?以及在近几年 AI 技术与应用快速演进的过程中,AI 应用的开发范式经历了哪些关键变化?
这一篇将不直接展开某一种 Agent 模式的实现细节,而是先回到更宏观的视角,从 AI 应用形态与工程范式的演进 入手,梳理 Agent 出现的技术背景与必然性。
需要说明的是,下文对 AI 应用演进阶段的划分,是一种以“应用开发范式”为核心的抽象总结。真实的技术演进在时间上存在明显重叠,但这种阶段化的叙述有助于我们理解:为什么 Agent 会在当下成为主流方向。
AI 应用的发展历程
第一阶段:提示词工程
2022 年 11 月,GPT-3.5 发布后,大模型开始从研究领域进入大众视野。对开发者来说,这是第一次可以在实际产品中直接使用通用语言模型。
这一阶段的 AI 应用形态非常简单,大多数产品本质上都是一个对话界面:用户输入问题 → 模型生成回答 → 结束。
很快,围绕 Prompt 的工程实践开始出现。由于模型对上下文非常敏感,系统提示词(System Prompt)成为当时最直接、也最有效的控制手段。常见的做法是通过提示词约束模型的角色、输出形式和关注重点,例如:
“你是一个资深的前端开发工程师,请严格以 JSON 格式输出结果……”
这类“身份面具”式的提示,本质上是通过上下文约束来减少模型输出的发散性,让结果更贴近预期。在这一阶段,也陆续出现了 Chain-of-Thought、Few-shot Prompting 等推理增强技巧,但它们依然属于单次生成模式:模型在一次调用中完成全部推理,过程中无法获得外部反馈,也无法根据中间结果调整策略。
第二阶段:RAG
当 AI 开始被用于真实业务场景时,很快暴露出两个问题:模型不了解私有知识,以及生成结果难以校验。以 GPT-3.5 为例,它的训练数据截止在 21 年左右,对于新技术以及企业内部文档、业务规则更是不了解,直接使用往往不可控。
RAG(Retrieval-Augmented Generation)是在这种背景下被广泛采用的方案。它的核心做法是:
- 将私有知识进行切分和向量化存储;
- 用户提问时,先进行相似度检索;
- 将命中的内容作为上下文提供给模型,再由模型完成生成。
通过这种方式,模型不需要记住所有知识,而是在生成时按需获取参考信息。
RAG 的价值不仅在于补充新知识,更重要的是带来了可控性和可追溯性:生成内容可以明确对应到原始文档,这一点在企业场景中尤为关键。
第三阶段:Tool Calling
如果说 RAG 让模型能够“查资料”,那么 Function / Tool Calling 则让模型开始能够“做事情”。
在这一阶段,开发者会把可用能力(如查询数据库、调用接口、执行脚本)以结构化的方式提供给模型,包括函数名、参数说明和功能描述。模型在理解用户意图后,可以返回一个明确的工具调用请求,再由程序完成实际执行。
这一能力的出现,标志着 AI 第一次在工程上具备了可靠调用外部系统的能力。它不再只是一个聊天机器人,而是一个可以触发真实世界动作的“控制器”,这也是后续 Agent 能够落地的关键技术支撑。
第四阶段:AI Workflow
当 RAG 能力和 Tool Calling 能力逐渐成熟后,开发者开始尝试把多个步骤组合起来,形成完整的业务流程。这催生了以 Dify、Coze 为代表的 AI Workflow 范式。
在 Workflow 模式下,一个 AI 应用会被拆解为多个固定节点,并按照预设顺序执行,例如:检索 → 判断 → 工具调用 → 汇总输出。
Workflow 的优势非常明显:
- 流程清晰,行为可预期;
- 易于测试和运营;
- 对非工程人员友好。
但问题也同样明显:流程完全由人设计,模型只是执行者。无论问题复杂与否,都必须走完整条路径。这种方式在应对高度动态或非标准任务时,灵活性有限。
第五阶段:Agent
在 Agent 出现之前,大多数 AI 应用仍然遵循一种典型模式:输入 → 单次/编排好的推理 → 输出。
而 Agent 的出现,本质上是将“任务编排”的控制权从人类手中交还给了 AI。在 Agent 架构下,AI 不再是被动执行一段代码,而是一个具备以下核心能力的闭环系统:
- 将复杂目标拆解为多个可执行步骤;
- 根据工具执行结果调整后续行动;
- 在失败时尝试修正策略;
- 在多步过程中维护上下文状态。
这些能力并不是一次模型调用完成的,而是通过多轮推理与执行形成闭环。也正是在这一点上,Agent 与前面的应用形态拉开了差距。
Agent 设计模式解决的问题
当 Agent 开始承担更复杂的任务时,问题也随之出现:
- 多步推理容易跑偏;
- 执行失败后缺乏统一的修正策略;
- 成本和稳定性难以控制。
Agent 设计模式的作用,就是把这些反复出现的问题抽象成可复用的结构。
无论是 ReAct,还是 Plan and Execute,它们关注的核心并不是“让模型更聪明”,而是:如何在工程上组织模型的推理、行动和反馈过程,使系统整体可控、可维护。
理解这些模式,有助于我们在构建 Agent 系统时少走弯路,而不是每一次都从零开始设计整套交互与控制逻辑。
结语
从最初基于 Prompt 的简单对话,到如今具备一定自主能力的 Agent,我们看到的不只是模型能力的提升,更是 AI 在实际使用方式上的变化。
回顾整个过程会发现,很多关键技术并不是最近才出现的。RAG 的核心思路早在几年前就已经被提出,ReAct 也并非新概念,只是在最近随着模型推理能力提升、工具链逐渐成熟,才真正具备了工程落地的条件。很多时候,并不是想法不存在,而是时机还没到。
理解这些演进背景,有助于我们判断哪些能力是短期噱头,哪些是长期方向。下一篇文章将聚焦 Agent 设计模式中最常见、也最实用的 ReAct 模式,结合实际实现,看看它是如何让 AI 在执行任务的过程中逐步思考、不断调整策略的。
参考资料
- 提示工程指南
- Chain-of-Thought Prompting Elicits Reasoning in Large Language Models.
- Language Models are Few-Shot Learners
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks.
- Toolformer: Language Models Can Teach Themselves to Use Tools.
- A Survey on Large Language Model based Autonomous Agents.
【节点】[LinearToGammaSpaceExact节点]原理解析与实际应用
线性颜色空间与伽马颜色空间基础概念
在计算机图形学中,颜色空间的管理是渲染流程中至关重要的环节。理解线性颜色空间和伽马颜色空间的区别对于创建逼真的渲染效果至关重要。
线性颜色空间指的是颜色数值与实际物理光强呈线性关系的颜色表示方式。在这种空间中,颜色值0.5表示的光强正好是颜色值1.0的一半。这种表示方式符合物理世界的真实光照行为,在渲染计算中能够产生准确的数学结果。
伽马颜色空间则是为了适应传统显示设备的非线性特性而设计的颜色表示系统。由于CRT显示器以及其他显示设备对输入信号的响应不是线性的,实际上显示设备会对输入信号进行伽马变换。在伽马空间中,颜色数值与最终显示的亮度之间不是简单的线性关系,而是遵循一个幂函数关系。
伽马校正的历史背景
伽马校正的概念起源于早期的CRT显示器时代。CRT显示器具有固有的非线性响应特性,其亮度输出与输入电压之间的关系大致符合幂函数规律,指数约为2.2。这意味着即使输入线性的颜色信号,显示器也会自动应用一个近似的伽马变换。
为了补偿这种非线性,图像和视频内容在创建时就会预先应用一个反向的伽马变换(指数约为1/2.2),这样当内容在显示器上显示时,两者的变换相互抵消,最终用户看到的就是正确的线性亮度关系。这种预处理过程就是所谓的伽马校正。
现代渲染中的伽马处理
在现代渲染管线中,虽然CRT显示器已逐渐被LCD、OLED等新技术取代,但伽马校正的概念仍然非常重要。主要原因包括:
- 向后兼容性:大量现有的图像内容和标准都是基于伽马空间设计的
- 感知均匀性:人类视觉系统对亮度的感知也是非线性的,伽马编码可以更有效地利用有限的比特深度
- 行业标准:sRGB等现代颜色标准仍然建立在伽马编码的基础上
LinearToGammaSpaceExact节点核心功能
LinearToGammaSpaceExact节点是Unity URP Shader Graph中专门用于颜色空间转换的工具节点。该节点执行从线性颜色空间到伽马颜色空间的精确数学转换,确保颜色数据在不同空间之间的准确转换。
数学原理
LinearToGammaSpaceExact节点实现的数学转换基于标准的sRGB伽马校正公式。转换过程使用分段函数来精确处理整个数值范围:
对于输入值小于或等于0.0031308的情况:
伽马值 = 12.92 × 线性值
对于输入值大于0.0031308的情况:
伽马值 = 1.055 × 线性值^(1/2.4) - 0.055
这种分段处理确保了在低亮度区域的线性关系和较高亮度区域的幂函数关系之间的平滑过渡,符合sRGB标准规范。
与近似方法的区别
Unity提供了两种伽马转换方法:LinearToGammaSpaceExact和LinearToGammaSpace。两者的主要区别在于精度和性能:
- LinearToGammaSpaceExact:使用精确的sRGB转换公式,计算结果准确但计算量稍大
- LinearToGammaSpace:使用近似的转换公式(通常为线性值^(1/2.2)),计算速度快但精度略低
在大多数情况下,两种方法的视觉差异不大,但在需要严格颜色准确性的场景中(如专业图像处理、颜色分级等),应优先使用Exact版本。
节点接口详解
![]()
输入端口
In输入端口接收Float类型的数值,代表线性颜色空间中的颜色分量。该端口可以接受以下类型的数值:
- 单个浮点数值:表示单颜色通道的线性值
- 二维向量:表示两个颜色通道的线性值
- 三维向量:表示RGB颜色的线性值
- 四维向量:表示RGBA颜色的线性值,包括透明度
输入值的有效范围通常是[0,1],但节点也可以处理超出此范围的HDR值,转换时会保持数值的相对关系。
输出端口
Out输出端口返回转换后的Float类型数值,表示伽马颜色空间中的颜色分量。输出值的范围与输入相对应:
- 对于标准范围[0,1]的输入,输出也在[0,1]范围内
- 对于HDR值(大于1),输出会保持相应的相对亮度关系
输出数据类型与输入保持一致,如果输入是向量类型,输出也是相应的向量类型,每个分量都独立进行伽马转换。
实际应用场景
后处理效果中的颜色校正
在后处理渲染中,LinearToGammaSpaceExact节点常用于将线性空间的计算结果转换为适合显示的伽马空间。例如,在实现色彩分级、色调映射或Bloom效果时:
- 在色调映射过程中,首先在线性空间中进行亮度压缩和颜色调整
- 使用LinearToGammaSpaceExact将结果转换到伽马空间
- 最终输出到屏幕缓冲区,确保显示设备能正确呈现
这种工作流程保证了颜色处理的准确性,避免了因颜色空间不匹配导致的颜色失真。
UI元素与渲染结果的混合
当需要将3D渲染结果与UI元素结合时,正确管理颜色空间至关重要:
- 3D渲染通常在线性空间中进行计算
- UI元素和纹理通常存储在伽马空间中
- 使用LinearToGammaSpaceExact可以将渲染结果转换到与UI一致的颜色空间
- 确保混合后的视觉效果颜色一致,没有明显的界限或差异
自定义光照模型开发
在开发自定义光照模型时,正确管理颜色空间是保证光照计算准确性的关键:
- 光照计算在线性空间中执行,符合物理规律
- 使用LinearToGammaSpaceExact将最终光照结果转换到显示空间
- 确保光照的亮度和颜色关系在显示时保持正确
特别是在实现复杂的PBR材质时,颜色空间的正确转换对于金属度、粗糙度等参数的准确表现尤为重要。
使用示例与案例分析
基础颜色空间转换
以下是一个简单的Shader Graph设置,演示如何使用LinearToGammaSpaceExact节点进行基本的颜色空间转换:
- 创建Color节点作为线性空间的颜色输入
- 将Color节点连接到LinearToGammaSpaceExact节点的In端口
- 将LinearToGammaSpaceExact节点的Out端口连接到主节点的Base Color输入
- 通过调节输入颜色,观察转换前后颜色的变化
这种基础设置可以帮助理解线性空间与伽马空间之间颜色表现的差异,特别是在中等亮度区域,差异最为明显。
HDR颜色处理案例
在处理高动态范围颜色时,LinearToGammaSpaceExact节点的行为值得特别关注:
// 假设在线性空间中有以下HDR颜色值
float3 linearColor = float3(2.0, 1.0, 0.5);
// 应用LinearToGammaSpaceExact转换
float3 gammaColor = LinearToGammaSpaceExact(linearColor);
// 结果会保持相对的亮度关系
// 但数值可能超出标准[0,1]范围
在实际应用中,通常会在伽马转换前先进行色调映射,将HDR值压缩到显示设备能够处理的范围内。
自定义后处理效果实现
下面是一个实现简单颜色分级效果的Shader Graph示例:
- 使用Scene Color节点获取当前渲染的线性空间颜色
- 应用颜色调整节点(如对比度、饱和度、色相调整)
- 所有调整在线性空间中执行,保证计算准确性
- 使用LinearToGammaSpaceExact节点将结果转换到伽马空间
- 输出到Blit命令或后处理堆栈
这种方法确保了颜色调整的物理准确性,避免了在伽马空间中进行调整可能引入的数学错误。
性能考量与优化建议
计算开销分析
LinearToGammaSpaceExact节点的计算开销主要来自幂函数计算和条件判断。虽然单个节点的开销不大,但在像素着色器中大量使用时仍需注意:
- 每个像素至少需要执行一次条件判断和一次幂运算
- 在高分辨率渲染中,这些操作会累积成可观的计算量
- 在移动平台或性能受限的环境中应谨慎使用
优化策略
针对性能敏感的场景,可以考虑以下优化策略:
- 在顶点着色器中进行转换:如果颜色数据在顶点间变化不大,可以在顶点阶段进行转换
- 使用近似版本:在视觉要求不高的场景中,使用LinearToGammaSpace替代Exact版本
- 批量处理:将多个颜色通道的转换合并处理,减少条件判断次数
- LUT优化:对于固定的颜色转换,可以使用查找表替代实时计算
平台特异性考虑
不同硬件平台对超越函数(如幂运算)的支持程度不同:
- 现代桌面GPU通常有专门的硬件单元处理这类运算,效率较高
- 移动GPU可能通过软件模拟,效率相对较低
- 在针对多平台开发时,应测试目标平台的性能表现
常见问题与解决方案
颜色显示不一致问题
在使用LinearToGammaSpaceExact节点时,可能会遇到颜色显示不一致的问题:
- 问题表现:在不同设备或不同查看条件下颜色显示有差异
- 可能原因:颜色空间配置错误、显示器校准不一致、图像格式不匹配
- 解决方案:确保整个渲染管线颜色空间设置一致,使用标准颜色配置文件,定期校准显示设备
性能瓶颈识别与解决
如果发现使用LinearToGammaSpaceExact节点后性能下降:
- 使用Unity Profiler分析着色器执行时间
- 检查是否在不需要精确转换的地方使用了Exact版本
- 考虑将转换移到渲染管线的后期阶段,减少重复计算
- 评估是否可以使用更简化的颜色空间处理方案
HDR与LDR工作流切换
在HDR和LDR渲染管线之间切换时,颜色空间处理需要特别注意:
- HDR管线通常在线性空间中处理更多计算
- LDR管线可能混合使用线性和伽马空间
- 使用LinearToGammaSpaceExact节点时应明确当前的颜色空间状态
- 建立统一的颜色空间管理策略,确保在不同管线间的一致性
最佳实践总结
正确使用LinearToGammaSpaceExact节点需要遵循一系列最佳实践:
- 始终了解数据当前所处的颜色空间,在线性空间中进行光照和颜色计算
- 仅在最终输出到屏幕或非浮点格式纹理时进行伽马转换
- 在需要最高颜色准确性的场景中使用Exact版本,其他情况可考虑使用近似版本
- 建立项目统一的颜色空间管理规范,避免混乱的颜色空间使用
- 定期测试在不同显示设备上的颜色表现,确保一致性
- 文档化颜色空间决策,便于团队协作和后续维护
通过遵循这些实践原则,可以确保渲染结果的视觉准确性,同时在性能和画质之间取得良好平衡。LinearToGammaSpaceExact节点作为颜色管理工具箱中的重要组件,在正确的使用场景下能够显著提升渲染质量。
【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)
《Flutter全栈开发实战指南:从零到高级》- 26 -持续集成与部署
引言
代码写得再好,没有自动化的流水线,就像法拉利引擎装在牛车上!!!
什么是持续集成与部署?简单说就是:
- 你写代码 → 自动测试 → 自动打包 → 自动发布
- 就像工厂的流水线,代码进去,App出来
今天我们一起来搭建这条"代码流水线",让你的开发效率大幅提升!
一:CI/CD到底是什么?为什么每个团队都需要?
1.1 从手动操作到自动化流水线
先看看传统开发流程的痛点:
// 传统发布流程(手动版)
1. 本地运行测试(); // 某些测试可能忘记运行
2. 手动打包Android(); // 配置证书、签名、版本号...
3. 手动打包iOS(); // 证书、描述文件、上架截图...
4. 上传到测试平台(); // 找测试妹子要手机号
5. 收集反馈修复bug(); // 来回沟通,效率低下
6. 重复步骤1-5(); // 无限循环...
再看自动化流水线:
# 自动化发布流程(CI/CD版)
流程:
1. 推送代码到GitHub/Gitlab → 自动触发
2. 运行所有测试 → 失败自动通知
3. 打包所有平台 → 同时进行
4. 分发到测试环境 → 自动分发给测试人员
5. 发布到应用商店 → 条件触发
1.2 CI/CD的核心价值
很多新手觉得CI/CD是"大公司才需要的东西",其实完全错了!它解决的是这些痛点:
问题1:环境不一致
本地环境: Flutter 3.10, Dart 2.18, Mac M1
测试环境: Flutter 3.7, Dart 2.17, Windows
生产环境: ???
问题2:手动操作容易出错 之前遇到过同事把debug包发给了用户,因为打包时选错了构建变体。
问题3:反馈周期太长 代码提交 → 手动打包 → 发给测试 → 发现问题 → 已经过了半天
1.3 CI/CD的三个核心概念
graph LR
A[代码提交] --> B[持续集成 CI]
B --> C[持续交付 CD]
C --> D[持续部署 CD]
B --> E[自动构建]
B --> F[自动测试]
C --> G[自动打包]
C --> H[自动发布到测试]
D --> I[自动发布到生产]
style A fill:#e3f2fd
style B fill:#f3e5f5
style C fill:#e8f5e8
style D fill:#fff3e0
持续集成(CI):频繁集成代码到主干,每次集成都通过自动化测试
持续交付(CD):自动将代码打包成可部署的产物
持续部署(CD):自动将产物部署到生产环境
注意:两个CD虽然缩写一样,但含义不同。Continuous Delivery(持续交付)和 Continuous Deployment(持续部署)
二:GitHub Actions
我们以github为例,当然各公司有单独部署的gitlab,大同小异这里不在赘述。。。
2.1 GitHub Actions工作原理
GitHub Actions不是魔法,而是GitHub提供的自动化执行环境。想象一下:
graph LR
A[你的代码仓库] --> B[事件推送/PR]
B --> C[GitHub Actions服务器]
C --> D[分配虚拟机]
D --> E[你的工作流]
E --> F[运行你的脚本]
style A fill:#f9f,stroke:#333,stroke-width:1px
style C fill:#9f9,stroke:#333,stroke-width:1px
style E fill:#99f,stroke:#333,stroke-width:1px
核心组件解析:
# 工作流组件关系图
工作流文件 (.github/workflows/ci.yml)
├── 触发器: 什么情况下运行 (push, pull_request)
├── 任务: 在什么环境下运行 (ubuntu-latest)
└── 步骤: 具体执行什么 (安装Flutter、运行测试)
2.2 创建你的第一个工作流
别被吓到,其实创建一个基础的CI流程只需要5分钟:
- 在项目根目录创建文件夹:
mkdir -p .github/workflows
- 创建CI配置文件:
# .github/workflows/flutter-ci.yml
name: Flutter CI # 工作流名称
# 触发条件:当有代码推送到main分支,或者有PR时
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
# 设置权限
permissions:
contents: read # 只读权限,保证安全
# 工作流中的任务
jobs:
# 任务1:运行测试
test:
# 运行在Ubuntu最新版
runs-on: ubuntu-latest
# 任务步骤
steps:
# 步骤1:检出代码
- name: Checkout code
uses: actions/checkout@v3
# 步骤2:安装Flutter
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.10.x' # 指定Flutter版本
channel: 'stable' # 稳定版
# 步骤3:获取依赖
- name: Get dependencies
run: flutter pub get
# 步骤4:运行测试
- name: Run tests
run: flutter test
# 步骤5:检查代码格式
- name: Check formatting
run: flutter format --set-exit-if-changed .
# 步骤6:静态分析
- name: Analyze code
run: flutter analyze
- 提交并推送代码:
git add .github/workflows/flutter-ci.yml
git commit -m "添加CI工作流"
git push origin main
推送到GitHub后,打开你的仓库页面,点击"Actions"标签,你会看到一个工作流正在运行!
2.3 GitHub Actions架构
graph TB
subgraph "GitHub Actions架构"
A[你的代码仓库] --> B[触发事件]
B --> C[GitHub Actions Runner]
subgraph "Runner执行环境"
C --> D[创建虚拟机]
D --> E[执行工作流]
subgraph "工作流步骤"
E --> F[检出代码]
F --> G[环境配置]
G --> H[执行脚本]
H --> I[产出物]
end
end
I --> J[结果反馈]
J --> K[GitHub UI显示]
J --> L[邮件/通知]
end
style A fill:#e3f2fd
style C fill:#f3e5f5
style E fill:#e8f5e8
style I fill:#fff3e0
核心概念解释:
- Runner:GitHub提供的虚拟机(或你自己的服务器),用来执行工作流
- Workflow:工作流,一个完整的自动化流程
- Job:任务,工作流中的独立单元
- Step:步骤,任务中的具体操作
- Action:可复用的操作单元,如"安装Flutter"
三:自动化测试流水线
3.1 为什么自动化测试如此重要?
功能上线前,全部功能手动测试耗时长,易出bug。加入自动化测试,有效减少bug率。
测试金字塔理论:
/\
/ \ E2E测试(少量)
/____\
/ \ 集成测试(适中)
/________\
/ \ 单元测试(大量)
/____________\
对于Flutter,测试分为三层:
3.2 配置单元测试
单元测试是最基础的,测试单个函数或类:
# .github/workflows/unit-tests.yml
name: Unit Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
strategy:
matrix:
# 在不同版本的Flutter上运行测试
flutter: ['3.7.x', '3.10.x']
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Flutter ${{ matrix.flutter }}
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ matrix.flutter }}
- name: Get dependencies
run: flutter pub get
- name: Run unit tests
run: |
# 运行所有单元测试
flutter test
# 生成测试覆盖率报告
flutter test --coverage
# 上传覆盖率报告
bash <(curl -s https://codecov.io/bash)
单元测试:
// test/calculator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/utils/calculator.dart';
void main() {
group('以Calculator测试为例', () {
late Calculator calculator;
// 准备工作
setUp(() {
calculator = Calculator();
});
test('两个正数相加', () {
expect(calculator.add(2, 3), 5);
});
test('正数与负数相加', () {
expect(calculator.add(5, -3), 2);
});
test('除以零应该抛出异常', () {
expect(() => calculator.divide(10, 0), throwsA(isA<ArgumentError>()));
});
});
}
3.3 配置集成测试
集成测试测试多个组件的交互:
# 集成测试工作流
jobs:
integration-tests:
runs-on: macos-latest # iOS集成测试需要macOS
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
- name: Get dependencies
run: flutter pub get
- name: Run integration tests
run: |
# 启动模拟器
# flutter emulators --launch flutter_emulator
# 运行集成测试
flutter test integration_test/
# 如果集成测试失败,上传截图辅助调试
- name: Upload screenshots on failure
if: failure()
uses: actions/upload-artifact@v3
with:
name: integration-test-screenshots
path: screenshots/
3.4 配置Widget测试
Widget测试测试UI组件:
jobs:
widget-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
- name: Install dependencies
run: |
flutter pub get
- name: Run widget tests
run: |
# 运行所有widget测试
flutter test test/widget_test.dart
# 或者运行特定目录
flutter test test/widgets/
3.5 测试流水线
sequenceDiagram
participant D as 开发者
participant G as Git仓库
participant CI as CI服务器
participant UT as 单元测试服务
participant WT as Widget测试服务
participant IT as 集成测试服务
participant R as 报告服务
participant N as 通知服务
D->>G: 推送代码
G->>CI: 触发Webhook
CI->>CI: 解析工作流配置
CI->>CI: 分配测试资源
par 并行执行
CI->>UT: 启动单元测试
UT->>UT: 准备环境
UT->>UT: 执行测试
UT->>UT: 分析覆盖率
UT-->>CI: 返回结果
and
CI->>WT: 启动Widget测试
WT->>WT: 准备UI环境
WT->>WT: 执行测试
WT->>WT: 截图对比
WT-->>CI: 返回结果
and
CI->>IT: 启动集成测试
IT->>IT: 准备设备
IT->>IT: 执行测试
IT->>IT: 端到端验证
IT-->>CI: 返回结果
end
CI->>CI: 收集所有结果
alt 所有测试通过
CI->>R: 请求生成报告
R->>R: 生成详细报告
R-->>CI: 返回报告
CI->>N: 发送成功通知
N-->>D: 通知开发者
else 有测试失败
CI->>R: 请求生成错误报告
R->>R: 生成错误报告
R-->>CI: 返回报告
CI->>N: 发送失败通知
N-->>D: 警报开发者
end
四:自动打包与发布流水线
4.1 Android自动打包
Android打包相对简单,但要注意签名问题:
# .github/workflows/android-build.yml
name: Android Build
on:
push:
tags:
- 'v*' # 只有打tag时才触发打包
jobs:
build-android:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
- name: Get dependencies
run: flutter pub get
- name: Setup keystore
# 从GitHub Secrets读取签名密钥
run: |
echo "${{ secrets.ANDROID_KEYSTORE }}" > android/app/key.jks.base64
base64 -d android/app/key.jks.base64 > android/app/key.jks
- name: Build APK
run: |
# 构建Release版APK
flutter build apk --release \
--dart-define=APP_VERSION=${{ github.ref_name }} \
--dart-define=BUILD_NUMBER=${{ github.run_number }}
- name: Build App Bundle
run: |
# 构建App Bundle
flutter build appbundle --release
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: android-build-${{ github.run_number }}
path: |
build/app/outputs/flutter-apk/app-release.apk
build/app/outputs/bundle/release/app-release.aab
4.2 iOS自动打包
iOS打包相对复杂,需要苹果开发者账号:
# .github/workflows/ios-build.yml
name: iOS Build
on:
push:
tags:
- 'v*'
jobs:
build-ios:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
- name: Install CocoaPods
run: |
cd ios
pod install
- name: Setup Xcode
run: |
# 设置Xcode版本
sudo xcode-select -s /Applications/Xcode_14.2.app
- name: Setup provisioning profiles
# 配置证书和描述文件
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE }}
run: |
# 导入证书
echo $BUILD_CERTIFICATE_BASE64 | base64 --decode > certificate.p12
# 创建钥匙链
security create-keychain -p "" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "" build.keychain
# 导入证书到钥匙链
security import certificate.p12 -k build.keychain \
-P $P12_PASSWORD -T /usr/bin/codesign
# 导入描述文件
echo $BUILD_PROVISION_PROFILE_BASE64 | base64 --decode > profile.mobileprovision
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
- name: Build iOS
run: |
# 构建iOS应用
flutter build ipa --release \
--export-options-plist=ios/ExportOptions.plist \
--dart-define=APP_VERSION=${{ github.ref_name }} \
--dart-define=BUILD_NUMBER=${{ github.run_number }}
- name: Upload IPA
uses: actions/upload-artifact@v3
with:
name: ios-build-${{ github.run_number }}
path: build/ios/ipa/*.ipa
4.3 多环境构建配置
真实的项目通常有多个环境:
# 多环境构建配置
env:
# 根据分支选择环境
APP_ENV: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
APP_NAME: ${{ github.ref == 'refs/heads/main' && '生产' || '测试' }}
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
# 同时构建多个Flavor
flavor: [development, staging, production]
platform: [android, ios]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
- name: Build ${{ matrix.platform }} for ${{ matrix.flavor }}
run: |
if [ "${{ matrix.platform }}" = "android" ]; then
flutter build apk --flavor ${{ matrix.flavor }} --release
else
flutter build ipa --flavor ${{ matrix.flavor }} --release
fi
- name: Upload ${{ matrix.flavor }} build
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.platform }}-${{ matrix.flavor }}
path: |
build/app/outputs/flutter-apk/app-${{ matrix.flavor }}-release.apk
build/ios/ipa/*.ipa
4.4 自动化发布到测试平台
构建完成后,自动分发给测试人员:
# 分发到测试平台
jobs:
distribute:
runs-on: ubuntu-latest
needs: [build] # 依赖build任务
steps:
- name: Download artifacts
uses: actions/download-artifact@v3
with:
path: artifacts/
- name: Upload to Firebase App Distribution
# 分发到Firebase
run: |
# 安装Firebase CLI
curl -sL https://firebase.tools | bash
# 登录Firebase
echo "${{ secrets.FIREBASE_TOKEN }}" > firebase_token.json
# 分发Android APK
firebase appdistribution:distribute artifacts/android-production/app-release.apk \
--app ${{ secrets.FIREBASE_ANDROID_APP_ID }} \
--groups "testers" \
--release-notes-file CHANGELOG.md
- name: Upload to TestFlight
# iOS上传到TestFlight
if: matrix.platform == 'ios'
run: |
# 使用altool上传到App Store Connect
xcrun altool --upload-app \
-f artifacts/ios-production/*.ipa \
-t ios \
--apiKey ${{ secrets.APPSTORE_API_KEY }} \
--apiIssuer ${{ secrets.APPSTORE_API_ISSUER }}
- name: Notify testers
# 通知测试人员
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
4.5 打包发布流水线
gantt
title Flutter打包发布流水线
dateFormat HH:mm
axisFormat %H:%M
section 触发与准备
代码提交检测 :00:00, 2m
环境初始化 :00:02, 3m
依赖安装 :00:05, 4m
section Android构建
Android环境准备 :00:05, 2m
Android代码编译 :00:07, 6m
Android代码签名 :00:13, 3m
Android打包 :00:16, 2m
section iOS构建
iOS环境准备 :00:05, 3m
iOS代码编译 :00:08, 8m
iOS证书配置 :00:16, 4m
iOS打包 :00:20, 3m
section 测试分发
上传到测试平台 :00:23, 5m
测试人员通知 :00:28, 2m
测试执行周期 :00:30, 30m
section 生产发布
测试结果评估 :01:00, 3m
生产环境准备 :01:03, 5m
提交到应用商店 :01:08, 10m
商店审核等待 :01:18, 30m
发布完成通知 :01:48, 2m
section 环境配置管理
密钥加载 :00:02, 3m
环境变量设置 :00:05, 2m
配置文件解析 :00:07, 3m
版本号处理 :00:10, 2m
五:环境配置管理
5.1 为什么需要环境配置管理?
先看一个反面教材:我们项目早期,不同环境的API地址是硬编码的:
// 不推荐:硬编码配置
class ApiConfig {
static const String baseUrl = 'https://api.production.com';
// 测试时需要手动改成:'https://api.staging.com'
// 很容易忘记改回来!
}
结果就是:测试时调用了生产接口,把测试数据插到了生产数据库!💥
5.2 多环境配置方案
方案一:基于Flavor的配置
// lib/config/flavors.dart
enum AppFlavor {
development,
staging,
production,
}
class AppConfig {
final AppFlavor flavor;
final String appName;
final String apiBaseUrl;
final bool enableAnalytics;
AppConfig({
required this.flavor,
required this.appName,
required this.apiBaseUrl,
required this.enableAnalytics,
});
// 根据Flavor创建配置
factory AppConfig.fromFlavor(AppFlavor flavor) {
switch (flavor) {
case AppFlavor.development:
return AppConfig(
flavor: flavor,
appName: 'MyApp Dev',
apiBaseUrl: 'https://api.dev.xxxx.com',
enableAnalytics: false,
);
case AppFlavor.staging:
return AppConfig(
flavor: flavor,
appName: 'MyApp Staging',
apiBaseUrl: 'https://api.staging.xxxx.com',
enableAnalytics: true,
);
case AppFlavor.production:
return AppConfig(
flavor: flavor,
appName: 'MyApp',
apiBaseUrl: 'https://api.xxxx.com',
enableAnalytics: true,
);
}
}
}
方案二:使用dart-define传入配置
# CI配置中传入环境变量
- name: Build with environment variables
run: |
flutter build apk --release \
--dart-define=APP_FLAVOR=production \
--dart-define=API_BASE_URL=https://api.xxxx.com \
--dart-define=ENABLE_ANALYTICS=true
// 在代码中读取环境变量
class EnvConfig {
static const String flavor = String.fromEnvironment('APP_FLAVOR');
static const String apiBaseUrl = String.fromEnvironment('API_BASE_URL');
static const bool enableAnalytics = bool.fromEnvironment('ENABLE_ANALYTICS');
}
5.3 管理敏感信息
敏感信息绝不能写在代码里!
# 使用GitHub Secrets
steps:
- name: Use secrets
env:
# 从Secrets读取
API_KEY: ${{ secrets.API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
SIGNING_KEY: ${{ secrets.ANDROID_SIGNING_KEY }}
run: |
# 在脚本中使用
echo "API Key: $API_KEY"
# 写入到配置文件
echo "{ \"apiKey\": \"$API_KEY\" }" > config.json
如何设置Secrets:
- 打开GitHub仓库 → Settings → Secrets and variables → Actions
- 点击"New repository secret"
- 输入名称和值
5.4 配置文件管理
推荐以下分层配置策略:
config/
├── .env.example # 示例文件,不含真实值
├── .env.development # 开发环境配置
├── .env.staging # 测试环境配置
├── .env.production # 生产环境配置
└── config_loader.dart # 配置加载器
// config/config_loader.dart
import 'package:flutter_dotenv/flutter_dotenv.dart';
class ConfigLoader {
static Future<void> load(String env) async {
// 根据环境加载对应的配置文件
await dotenv.load(fileName: '.env.$env');
}
static String get apiBaseUrl => dotenv.get('API_BASE_URL');
static String get apiKey => dotenv.get('API_KEY');
static bool get isDebug => dotenv.get('DEBUG') == 'true';
}
// main.dart
void main() async {
// 根据编译模式选择环境
const flavor = String.fromEnvironment('FLAVOR', defaultValue: 'development');
await ConfigLoader.load(flavor);
runApp(MyApp());
}
5.5 设计环境配置
graph TB
subgraph "环境配置管理架构"
A[配置来源] --> B[优先级]
subgraph "B[优先级]"
B1[1. 运行时环境变量] --> B2[最高优先级]
B3[2. 配置文件] --> B4[中等优先级]
B5[3. 默认值] --> B6[最低优先级]
end
A --> C[敏感信息处理]
subgraph "C[敏感信息处理]"
C1[密钥/密码] --> C2[GitHub Secrets]
C3[API令牌] --> C4[环境变量注入]
C5[数据库连接] --> C6[运行时获取]
end
A --> D[环境类型]
subgraph "D[环境类型]"
D1[开发环境] --> D2[本地调试]
D3[测试环境] --> D4[CI/CD测试]
D5[预发环境] --> D6[生产前验证]
D7[生产环境] --> D8[线上用户]
end
B --> E[配置合并]
C --> E
D --> E
E --> F[最终配置]
F --> G[应用启动]
F --> H[API调用]
F --> I[功能开关]
end
subgraph "安全实践"
J[永远不要提交] --> K[.env文件到Git]
L[使用.gitignore] --> M[忽略敏感文件]
N[定期轮换] --> O[密钥和令牌]
P[最小权限原则] --> Q[仅授予必要权限]
end
style A fill:#e3f2fd
style C fill:#f3e5f5
style D fill:#e8f5e8
style J fill:#fff3e0
六:常见CI/CD技巧
6.1 使用缓存加速构建
Flutter项目依赖下载很慢,使用缓存可以大幅提速:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Cache Flutter dependencies
uses: actions/cache@v3
with:
path: |
/opt/hostedtoolcache/flutter
${{ github.workspace }}/.pub-cache
${{ github.workspace }}/build
key: ${{ runner.os }}-flutter-${{ hashFiles('pubspec.lock') }}
restore-keys: |
${{ runner.os }}-flutter-
- name: Cache Android dependencies
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
6.2 构建策略
同时测试多个配置组合:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
# 定义
os: [ubuntu-latest, macos-latest]
flutter-version: ['3.7.x', '3.10.x']
exclude:
- os: macos-latest
flutter-version: '3.7.x'
# 包含特定组合
include:
- os: windows-latest
flutter-version: '3.10.x'
channel: 'beta'
steps:
- name: Test on ${{ matrix.os }} with Flutter ${{ matrix.flutter-version }}
run: echo "Running tests..."
6.3 条件执行与工作流控制
jobs:
deploy:
# 只有特定分支才执行
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Check changed files
# 只有特定文件改动才执行
uses: dorny/paths-filter@v2
id: changes
with:
filters: |
src:
- 'src/**'
configs:
- 'config/**'
- name: Run if src changed
if: steps.changes.outputs.src == 'true'
run: echo "Source code changed"
- name: Skip if only docs changed
if: github.event_name == 'pull_request' && contains(github.event.pull_request.title, '[skip-ci]')
run: |
echo "Skipping CI due to [skip-ci] in PR title"
exit 0
6.4 自定义Actions
当通用Actions不够用时,可以自定义:
# .github/actions/flutter-setup/action.yml
name: 'Flutter Setup with Custom Options'
description: 'Setup Flutter environment with custom configurations'
inputs:
flutter-version:
description: 'Flutter version'
required: true
default: 'stable'
channel:
description: 'Flutter channel'
required: false
default: 'stable'
enable-web:
description: 'Enable web support'
required: false
default: 'false'
runs:
using: "composite"
steps:
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ inputs.flutter-version }}
channel: ${{ inputs.channel }}
- name: Enable web if needed
if: ${{ inputs.enable-web == 'true' }}
shell: bash
run: flutter config --enable-web
- name: Install licenses
shell: bash
run: flutter doctor --android-licenses
七:为现有项目添加CI/CD
7.1 分析现有项目
如果我们有一个现成的Flutter应用,需要添加CI/CD:
项目结构:
my_flutter_app/
├── lib/
├── test/
├── android/
├── ios/
└── pubspec.yaml
当前问题:
- 手动测试,经常漏测
- 打包需要20分钟,且容易出错
- 不同开发者环境不一致
- 发布流程繁琐
7.2 分阶段实施自动化
第一阶段:实现基础CI
- 添加基础测试流水线
- 代码质量检查
- 配置GitHub Actions
第二阶段:自动化构建
- Android自动打包
- iOS自动打包
- 多环境配置
第三阶段:自动化发布
- 测试环境自动分发
- 生产环境自动发布
- 监控与告警
7.3 配置文件
# .github/workflows/ecommerce-ci.yml
name: E-commerce App CI/CD
on:
push:
branches: [develop]
pull_request:
branches: [main, develop]
schedule:
# 每天凌晨2点跑一遍测试
- cron: '0 2 * * *'
jobs:
# 代码质量
quality-gate:
runs-on: ubuntu-latest
outputs:
passed: ${{ steps.quality-check.outputs.passed }}
steps:
- uses: actions/checkout@v3
- name: Quality Check
id: quality-check
run: |
# 代码规范检查
flutter analyze . || echo "::warning::Code analysis failed"
# 检查测试覆盖率
flutter test --coverage
PERCENTAGE=$(lcov --summary coverage/lcov.info | grep lines | awk '{print $4}' | sed 's/%//')
if (( $(echo "$PERCENTAGE < 80" | bc -l) )); then
echo "::error::Test coverage $PERCENTAGE% is below 80% threshold"
echo "passed=false" >> $GITHUB_OUTPUT
else
echo "passed=true" >> $GITHUB_OUTPUT
fi
# 集成测试
integration-test:
needs: quality-gate
if: needs.quality-gate.outputs.passed == 'true'
runs-on: macos-latest
services:
# 启动测试数据库
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
- name: Run integration tests with database
env:
DATABASE_URL: postgres://postgres:postgres@postgres:5432/test_db
run: |
flutter test integration_test/ --dart-define=DATABASE_URL=$DATABASE_URL
# 性能测试
performance-test:
needs: integration-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run performance benchmarks
run: |
# 运行性能测试
flutter drive --target=test_driver/app_perf.dart
# 分析性能数据
dart analyze_performance.dart perf_data.json
- name: Upload performance report
uses: actions/upload-artifact@v3
with:
name: performance-report
path: perf_report.json
# 安全扫描
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run security scan
uses: snyk/actions/dart@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
- name: Check for secrets in code
uses: trufflesecurity/trufflehog@main
with:
path: ./
# 报告
report:
needs: [quality-gate, integration-test, performance-test, security-scan]
runs-on: ubuntu-latest
if: always()
steps:
- name: Generate CI/CD Report
run: |
echo "# CI/CD Run Report" > report.md
echo "## Run: ${{ github.run_id }}" >> report.md
echo "## Status: ${{ job.status }}" >> report.md
echo "## Jobs:" >> report.md
echo "- Quality Gate: ${{ needs.quality-gate.result }}" >> report.md
echo "- Integration Test: ${{ needs.integration-test.result }}" >> report.md
echo "- Performance Test: ${{ needs.performance-test.result }}" >> report.md
echo "- Security Scan: ${{ needs.security-scan.result }}" >> report.md
- name: Upload report
uses: actions/upload-artifact@v3
with:
name: ci-cd-report
path: report.md
7.4 流程优化
CI/CD不是一次性的,需要持续优化:
# 监控CI/CD性能
name: CI/CD Performance Monitoring
on:
workflow_run:
workflows: ["E-commerce App CI/CD"]
types: [completed]
jobs:
analyze-performance:
runs-on: ubuntu-latest
steps:
- name: Download workflow artifacts
uses: actions/github-script@v6
with:
script: |
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
// 分析执行时间
const runDuration = new Date(context.payload.workflow_run.updated_at) -
new Date(context.payload.workflow_run.run_started_at);
console.log(`Workflow took ${runDuration / 1000} seconds`);
// 发送到监控系统
// ...
- name: Send to monitoring
run: |
# 发送指标到Prometheus/Grafana
echo "ci_duration_seconds $DURATION" | \
curl -X POST -H "Content-Type: text/plain" \
--data-binary @- http://monitoring.xxxx.com/metrics
八:常见问题
8.1 GitHub Actions常见问题
Q:工作流运行太慢怎么办?
A:优化手段:
# 1. 使用缓存
- uses: actions/cache@v3
with:
path: ~/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('pubspec.lock') }}
# 2. 并行执行独立任务
jobs:
test-android:
runs-on: ubuntu-latest
test-ios:
runs-on: macos-latest
# 两个任务会并行执行
# 3. 项目大可以考虑使用自托管Runner
runs-on: [self-hosted, linux, x64]
Q:iOS构建失败,证书问题?
A:iOS证书配置流程:
# 1. 导出开发证书
openssl pkcs12 -in certificate.p12 -out certificate.pem -nodes
# 2. 在GitHub Secrets中存储
# 使用base64编码
base64 -i certificate.p12 > certificate.txt
# 3. 在CI中还原
echo "${{ secrets.IOS_CERTIFICATE }}" | base64 --decode > certificate.p12
security import certificate.p12 -k build.keychain -P "${{ secrets.CERT_PASSWORD }}"
Q:如何调试失败的CI?
A:调试技巧:
# 1. 启用调试日志
run: |
# 显示详细日志
flutter build apk --verbose
# 或使用环境变量
env:
FLUTTER_VERBOSE: true
# 2. 上传构建日志
- name: Upload build logs
if: failure()
uses: actions/upload-artifact@v3
with:
name: build-logs
path: |
~/flutter/bin/cache/
build/
# 3. 使用tmate进行SSH调试
- name: Setup tmate session
uses: mxschmitt/action-tmate@v3
if: failure() && github.ref == 'refs/heads/main'
8.2 Flutter问题
Q:不同版本兼容性?
A:版本管理策略:
# 使用版本测试兼容性
strategy:
matrix:
flutter-version: ['3.7.x', '3.10.x', 'stable']
# 在代码中检查版本
void checkFlutterVersion() {
const minVersion = '3.7.0';
final currentVersion = FlutterVersion.instance.version;
if (Version.parse(currentVersion) < Version.parse(minVersion)) {
throw Exception('Flutter version $minVersion or higher required');
}
}
Q:Web构建失败?
A:Web构建配置:
# 确保启用Web支持
- name: Enable web
run: flutter config --enable-web
# 构建Web版本
- name: Build for web
run: |
flutter build web \
--web-renderer canvaskit \
--release \
--dart-define=FLUTTER_WEB_USE_SKIA=true
# 处理Web特定问题
- name: Fix web issues
run: |
# 清理缓存
flutter clean
# 更新Web引擎
flutter precache --web
8.3 安全与权限问题
Q:如何管理敏感信息?
A:安全实践:
# 1. 使用环境级别的Secrets
env:
SUPER_SECRET_KEY: ${{ secrets.PRODUCTION_KEY }}
# 2. 最小权限原则
permissions:
contents: read
packages: write # 只有需要时才写
# 3. 使用临时凭证
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
# 4. 定期轮换密钥
# 设置提醒每月更新一次Secrets
最后
通过这篇教程我们掌握了Flutter CI/CD的核心知识,一个完美的流水线是一次次迭代出来的,需要不断优化。如果觉得文章对你有帮助,别忘了一键三连,支持一下
有任何问题或想法,欢迎在评论区交流讨论。