阅读视图

发现新文章,点击刷新页面。

GDPS2026上海市人工智能行业协会与毕马威中国战略合作,共创智能化转型破局之路

当前,人工智能正从技术爆发期迈向产业深水区,传统企业站在了智能化转型的关键路口。与信息化、数字化时代不同,AI转型并非简单的系统升级或流程优化,而是一场涉及战略重构、组织变革与业务重塑的系统性工程。大量传统企业面对AI浪潮时,普遍陷入“不敢转、不会转、转不动”的困境,既担忧技术投入回报不确定,也困惑于原有方法论无法适配AI逻辑,行业亟需一套兼具前瞻性与落地性的AI转型解决方案。

值此GDPS2026全球开发者先锋大会之际,上海市人工智能行业协会(以下简称“SAIA”)与毕马威中国(以下简称“KPMG”)正式签署战略合作协议。双方同意在产业研究、活动合作与品牌共建、企业服务与生态建设、标准制定、咨询项目合作等多个领域开展全面合作。SAIA产业实践经验与KPMG专业方法论深度融合,联合推出面向传统企业智能化转型、具备可落地性的解决方案。

图1-SAIA与KPMG战略合作协议签约仪式

一、传统企业在推进AI转型过程中面临多重痛点与深层焦虑

AI技术已展现出巨大潜力,但传统企业在实际推进转型时,仍普遍面临从战略到执行的多重障碍,主要从以下从五个维度剖析:

首先,战略层面“方向不清”是最大困扰。许多管理者虽已意识到AI的重要性,却难以回答“AI能为业务带来什么”“应从哪个环节切入”等根本性问题,导致转型停留在概念阶段,迟迟无法落地。

其次,业务层面“场景难寻”成为突出瓶颈。AI技术需与具体业务场景深度结合才能产生价值,但传统企业业务链条复杂、流程固化,识别高价值、可落地的AI应用场景本身就需要极高的专业判断力,企业往往在试错中消耗大量资源。

第三,组织层面“复合人才匮乏”构成硬约束。既懂AI技术又懂业务场景的复合型人才极度稀缺,企业内部现有团队的知识结构难以支撑AI项目的高效推进。

第四,成本层面“投入焦虑”尤为突出。AI项目前期投入大、见效周期长,结果高度不确定,决策层常在“怕错过”与“怕投错”之间反复权衡。

最后,路径层面“方法缺失”是深层隐忧。更值得关注的是,传统信息化、数字化转型方法论在面对AI转型时,已暴露出明显的不适应性,企业迫切需要一套真正适配AI特性的专业方法论作为指导。

这些痛点与不足交织叠加,构成了传统企业AI转型的第一道难关。

二、双方构建可落地的AI转型解决方案

基于SAIA与KPMG的联合,双方构建了一套真正可落地、可复制、可迭代的AI转型解决方案,助力企业跨越从战略到执行的鸿沟。

图2 毕马威中国人工智能转型办公室主管合伙人分享AI转型方法框架

第一,以AI转型方法论为根基。方案以SAIA与KPMG联合提炼的AI转型方法论为基石,围绕“战略—场景—数据—组织—生态”五大维度,构建从顶层设计到落地执行的全链路闭环。该方案既深刻反思了传统信息化范式的局限,又充分融合了AI技术特性与企业实际场景,为转型提供了清晰的路径指引。

第二,采用人工智能成熟度评估模型。通过系统化的诊断模型,从战略认知、数据基础、技术能力、组织文化四个维度,精准评估企业当前的AI准备度,明确优势与短板,为后续规划提供客观依据。

第三,顶层设计AI转型战略。基于评估结果,协助企业制定“短期见效、中期突破、长期布局”的AI战略蓝图,明确愿景目标、资源投入、组织保障与关键里程碑,确保AI战略与企业整体发展同频共振。

第四,对接智能体建设解决方案。聚焦AI能力的具体落地,围绕智能体(AI Agent)的规划、开发、部署与运营,提供覆盖单点辅助、流程自动化、知识问答等多元场景的解决方案,推动AI从“工具”向“协作伙伴”演进。

第五,构建前沿部署工程师(FDE)赋能机制。为确保方案真正落地,引入“前沿部署工程师”(Forward Deployment Engineer,FDE)机制。FDE作为技术与业务之间的桥梁,深入企业一线,全程参与需求调研、方案设计、系统部署与迭代优化,确保AI能力与业务场景深度融合,帮助企业在转型过程中构建“自我造血”的持续进化能力。

三、SAIA与KPMG联合的优势分析

在推动传统企业智能化转型进程中,SAIA与KPMG各自拥有不可替代的核心优势,双方的强强联合更形成了“产业洞察+专业方法论”的深度协同效应。

SAIA作为上海市人工智能领域的权威行业组织,以“政府助手、企业帮手、行业推手”为定位,在产业推动中发挥“连接器、推进器、放大器”的关键作用。其一,产业生态优势:SAIA一端链接海量AI创新企业、开发者和创业者,打造“最后一公里”赋能平台;另一端汇聚各行业场景需求与央国企转型痛点,响应国家“AI+”任务。凭借大量已验证的AI+成功案例,精准推动技术与场景的匹配,让AI创新企业找到落地土壤,让AI转型企业获得可复用的标杆实践,形成双向奔赴的产业生态。其二,政策衔接优势:作为政企桥梁,深度参与地方AI发展规划与政策制定,助力企业把握政策导向、争取资源扶持。其三,标准引领优势:在标准制定、伦理治理、可信AI等领域持续深耕,为企业提供合规指引。其四,资源链接优势:通过GDPS全球开发者先锋大会、闭门会等平台,加速资本、技术、人才的流动与聚合。

KPMG作为全球领先的专业服务机构,在咨询、审计、税务等领域拥有深厚积淀。其一,战略咨询能力:长期服务世界500强及中国头部企业,具备从战略规划到落地执行的全链路咨询能力。其二,行业深耕优势:在金融、制造、医疗、汽车、政务等重点行业拥有专业团队,精准匹配AI技术与业务场景。其三,方法论体系:构建了涵盖战略设计、场景挖掘、数据治理、组织演进、生态协同的AI转型方法论。其四,全球视野与本地实践:融合国际领先经验与本土企业实际需求。

SAIA与KPMG的联合,形成了“产业生态+专业服务”的深度融合。双方实现产研结合,确保方案前瞻性与落地性并重;标准与实施并重,助力企业合规高效部署AI;资源协同,为企业提供政策对接、技术选型、人才赋能的一站式服务;价值闭环,打通从战略诊断到组织演进的完整链条。双方联合,形成了“懂产业、懂技术、懂方法、懂落地”的综合能力,为传统企业智能化转型提供坚实支撑。

四、期待合作带来的效果

依托SAIA与KPMG联合构建的AI转型解决方案,中国传统企业将在智能化浪潮中迎来信心与未来。AI将深度重构供应链优化、生产排程、设备维护、客户服务等关键流程,为企业注入降本增效、提质增收的新动能。

决策层面,AI驱动的实时洞察将推动管理者从经验主导迈向数据驱动,让决策更精准、更高效。业务拓展上,AI赋能智能营销、产品服务化转型与研发提速,为企业打开新的增长空间。组织层面,AI转型促进业务与技术深度融合,培育复合型人才,夯实可持续的智能化能力。

更值得期待的是,这套方案将赋予企业拥抱变革的勇气与底气。在智能化时代,敢于先行、勇于布局的企业,将率先抵达高质量发展的新彼岸。SAIA与KPMG愿与各界携手,以专业之力助推企业加速驶入智能化快车道,共同开创更加光明的未来。

工信部:有攻击者利用针对苹果公司终端产品的漏洞利用工具实施网络攻击活动,可导致信息窃取等严重危害

36氪获悉,近日,工业和信息化部网络安全威胁和漏洞信息共享平台(NVDB)监测发现,有攻击者利用针对苹果公司终端产品的漏洞利用工具实施网络攻击活动,可导致信息窃取、系统受控等严重危害。影响范围包括运行iOS 13.0至17.2.1的iPhone、iPad等苹果公司终端产品。攻击者通过短信、邮件或网页投毒等方式,诱导用户使用Safari浏览器访问包含恶意代码的网页,综合利用终端设备中存在的安全漏洞,向受害终端产品植入远程控制木马,窃取用户敏感信息,获取最高权限并控制。

天猫健康联合数十家品牌成立“保健品安心联盟”

4月3日,天猫健康联合汤臣倍健、钙尔奇、善存、哈药、21金维他、康恩贝、健力多、养生堂、寿仙谷、森山、诺惠等数十家国内知名保健品企业,共同启动“保健品安心联盟”。联盟旨在推动保健品行业标准化、规范化、透明化,倡导消费者购买保健品认准“蓝帽子”标识,选择更适合中国人营养保健需求的产品。

鸿蒙日历服务实践:把应用里的事件写进用户的日程表

引言

很多应用都会产生带时间属性的事件——买了张火车票、预约了一场直播、信用卡该还款了、晚上有节线上课。这些信息散落在各个应用里,用户需要自己记住或者手动添加提醒,难免遗漏。

鸿蒙的 Calendar Kit 提供了一种更好的方式:让应用直接把这些事件写入系统日历。写入后,日程会通过通知中心、桌面卡片、日历应用等多个入口触达用户,还能配上一个**"一键服务"按钮**,用户点一下就能跳回应用完成操作——比如"加入会议"、"马上还款"、"立即观看"。

本文面向希望接入日历服务的鸿蒙开发者,梳理日历日程的创建机制、一键服务的场景设计,并通过两个典型场景的完整实现,帮你快速理解开发要点。


一、日历服务能做什么

1.1 核心能力

简单说就是三件事:

  1. 写入日程:把应用中带时间属性的事件以标准格式写入系统日历,包括标题、时间、地点、备注等信息。
  2. 提醒用户:通过系统级的提醒机制,在日程开始前的指定时间通知用户。
  3. 一键直达:在日程卡片上提供服务按钮,用户点击后通过 DeepLink 跳回应用的对应页面,完成后续操作。

写入日历的日程会出现在多个地方——日历应用内部、桌面日历卡片、通知中心。用户不需要打开你的应用就能看到这些信息,这对提升事件的到达率很有帮助。

1.2 一键服务按钮的显示时机

一键服务按钮不是一直显示的,不同入口的出现时机不一样:

入口 显示时机
桌面卡片 / 月视图日程列表卡片 日程开始前 15 分钟显示,日程结束后自动隐藏
日程详情页 始终显示
日程通知 通知弹出时显示,点击通知卡片后显示

这意味着一键服务按钮是有"时效性"的——它在用户最需要行动的时间窗口出现,而不是一直挂在那里。

1.3 9 种典型服务场景

Calendar Kit 为不同的业务场景预定义了服务类型,每种类型对应一个具体的按钮文案:

场景 ServiceType 按钮文案
会议 Meeting 加入会议
追剧 Watching 立即观看
还款 Repayment 马上还款
直播 Live 开启直播
购物 Shopping 开始选购
出行 Trip 立即查看
上课 Class 开始上课
赛事 SportsEvents 立即观看
运动 SportsExercise 开始运动

选择合适的服务类型,按钮文案就会自动匹配,不需要开发者自定义。


二、开发前的准备工作

在写第一行业务代码之前,有三步准备工作需要完成:

第一步,导入依赖。日历管理相关的能力都在 @kit.CalendarKit 中。

第二步,申请权限。日历是用户的私有数据,读写操作需要在 module.json5 中声明两个权限:ohos.permission.READ_CALENDARohos.permission.WRITE_CALENDAR

第三步,获取日程管理器对象。通过上下文获取 calendarMgr 对象,后续所有日历账户和日程的管理操作都通过它来进行。推荐在 EntryAbility.ets 中完成这一步,确保管理器对象在应用生命周期内可用。


三、理解日程的数据结构

在看具体场景之前,先理解日程涉及的几个关键概念,后面写代码时会更清楚为什么要这样配置。

3.1 日历账户

每个写入系统日历的日程都归属于一个日历账户。你可以理解为日历中的一个"分组"——用户打开日历应用时,能看到来自不同应用的日程被归类在各自的账户下。

账户有三个关键属性:

  • name:账户标识,供系统内部使用。
  • type:账户类型,一般使用 LOCAL
  • displayName:展示给用户看的名称,建议和应用在应用市场中的名称保持一致,方便用户识别"这个日程是哪个应用写的"。

3.2 日程字段

一条日程的核心字段包括:

  • title:标题,出现在日程卡片上最醒目的位置。
  • startTime / endTime:起止时间,时间戳格式。
  • isAllDay:是否是全天日程。全天日程不会显示具体时刻,适合"入住日""还款日"这类以天为单位的事件。
  • reminderTime:提醒时间,是一个数组,单位是分钟。比如 [0, 10] 表示日程开始时和开始前 10 分钟各提醒一次。对于全天日程,0 表示当天上午 9 点提醒,1440(即 24 小时)表示前一天上午 9 点提醒。
  • description:备注信息,可以补充标题里放不下的细节。
  • location:地点信息,包含地址文本和经纬度。
  • service:一键服务配置,包括服务类型(type)和跳转链接(uri,DeepLink 格式)。

3.3 日程的增删改查

日历服务提供了完整的 CRUD 操作。创建日历账户后,可以在该账户下添加日程、按条件查询日程、更新日程信息、删除日程。后面的场景示例中会展示这些操作的具体写法。


四、典型场景实践

下面通过两个最常见的场景——出行服务会议——来完整走一遍开发流程。其他场景(直播、购物、还款、课程等)的开发思路完全一致,区别只在于字段内容和 ServiceType 的选择。

4.1 出行服务场景

这大概是最容易理解的场景了:用户在购票应用里买了一张高铁票,应用把行程信息写入日历,出发前提醒用户,用户还能一键跳回应用查看电子客票。

字段设计思路:

标题要一目了然,建议包含车次和起终点,比如"行程信息:G107 上海虹桥-北京南"。备注里可以放检票口和座位号这类到了车站才需要的细节。提醒时间设两个——4 小时前提醒用户该出门了,2 小时前再提醒一次。一键服务类型选 TRIP

创建日程:

import { calendarMgr } from '../entryability/EntryAbility';
import { calendarManager } from '@kit.CalendarKit';

let tripCalendar: calendarManager.Calendar | undefined = undefined;
let oriEvent: calendarManager.Event | null = null;
let id: number = 0;

async createTripCalendarAndEvent(): Promise<void> {
  const calendarAccount: calendarManager.CalendarAccount = {
    name: 'TripCalendar',
    type: calendarManager.CalendarType.LOCAL,
    displayName: '高铁出行'
  };

  const config: calendarManager.CalendarConfig = {
    color: '#aabbcc'
  };

  const startTime = new Date('2025-10-01T08:17:00').getTime();
  const endTime = new Date('2025-10-01T12:51:00').getTime();

  const event: calendarManager.Event = {
    type: calendarManager.EventType.NORMAL,
    title: '行程信息:G107 上海虹桥-北京南',
    startTime: startTime,
    endTime: endTime,
    isAllDay: false,
    reminderTime: [120, 240],
    description: '检票口:南二楼1口或北广场B2候车室 \n座位号:02车04二等座',
    service: {
      type: calendarManager.ServiceType.TRIP,
      uri: 'demo://mobile/player?params='
    }
  };

  try {
    tripCalendar = await calendarMgr?.createCalendar(calendarAccount);
    if (!tripCalendar) {
      console.error('Failed to create calendar.');
      return;
    }
    await tripCalendar.setConfig(config);
    id = await tripCalendar.addEvent(event);
    oriEvent = event;
    oriEvent.id = id;
    console.info(`日程创建成功,ID: ${id}`);
  } catch (error) {
    console.error(`创建失败: ${error.code}, ${error.message}`);
  }
}

这段代码做了三件事:创建日历账户、设置账户配色、添加日程。注意一定要确保日历账户创建成功后再进行日程操作,否则后续调用会失败。

日程的后续管理:

出行场景下,行程变更是常有的事——改签了车次、换了出发时间。这时候需要更新已有日程而不是删掉重建:

async updateTripEvent(): Promise<void> {
  if (!tripCalendar || !oriEvent) return;
  
  // 改签后更新起止时间
  oriEvent.startTime = new Date('2025-10-01T07:03:00').getTime();
  oriEvent.endTime = new Date('2025-10-01T11:51:00').getTime();

  try {
    await tripCalendar.updateEvent(oriEvent);
    console.info('日程更新成功');
  } catch (err) {
    console.error(`更新失败: ${err.code}, ${err.message}`);
  }
}

如果用户退票了,则直接删除日程:

async deleteTripEvent(): Promise<void> {
  if (!tripCalendar) return;
  try {
    await tripCalendar.deleteEvent(id);
    oriEvent = null;
    console.info('日程已删除');
  } catch (err) {
    console.error(`删除失败: ${err.code}, ${err.message}`);
  }
}

需要查询已有日程时,通过 EventFilter.filterById 按 ID 查询:

async getTripEvent(): Promise<void> {
  if (!tripCalendar) return;
  try {
    const filter = calendarManager.EventFilter.filterById([id]);
    let data = await tripCalendar.getEvents(filter, 
      ['title', 'type', 'startTime', 'endTime']);
    if (data && data.length > 0) {
      oriEvent = data[0];
    }
  } catch (err) {
    console.error(`查询失败: ${err.code}, ${err.message}`);
  }
}

4.2 会议场景

会议场景和出行的最大区别在于:它有与会人信息。用户在会议应用中创建或被邀请参加一个会议,应用将其写入日历,到时间时用户看到提醒,点击"加入会议"按钮就能直接进入会议。

字段设计思路:

标题就是会议主题。提醒时间设准时和 15 分钟前——太早没意义,太晚来不及。会议场景特有的是 attendee 字段,用来记录与会人信息,每个与会人有姓名、邮箱、角色(组织者还是参与者)和类型(必选还是可选)。

async createMeetingEvent(): Promise<void> {
  const calendarAccount: calendarManager.CalendarAccount = {
    name: 'meetingCalendar',
    type: calendarManager.CalendarType.LOCAL,
    displayName: '会议'
  };

  const config: calendarManager.CalendarConfig = {
    color: '#aabbcc'
  };

  let attendee: calendarManager.Attendee[] = [
    {
      name: 'Alice',
      email: 'alice@example.com',
      role: calendarManager.AttendeeRole.ORGANIZER
    },
    {
      name: 'Jack',
      email: 'jack@example.com',
      role: calendarManager.AttendeeRole.PARTICIPANT,
      type: calendarManager.AttendeeType.REQUIRED
    },
    {
      name: 'Jerry',
      email: 'jerry@example.com',
      role: calendarManager.AttendeeRole.PARTICIPANT,
      type: calendarManager.AttendeeType.REQUIRED
    }
  ];

  const startTime = new Date('2025-10-20T09:00:00').getTime();
  const endTime = new Date('2025-10-20T10:00:00').getTime();

  const event: calendarManager.Event = {
    type: calendarManager.EventType.NORMAL,
    title: '产品方案评审会议',
    startTime: startTime,
    endTime: endTime,
    isAllDay: false,
    reminderTime: [0, 15],
    attendee: attendee,
    description: 'Q4产品方案评审',
    service: {
      type: calendarManager.ServiceType.MEETING,
      uri: 'demo://mobile/player?params='
    }
  };

  try {
    let calendar = await calendarMgr?.createCalendar(calendarAccount);
    if (!calendar) return;
    await calendar.setConfig(config);
    id = await calendar.addEvent(event);
    console.info(`会议日程创建成功,ID: ${id}`);
  } catch (error) {
    console.error(`创建失败: ${error.code}, ${error.message}`);
  }
}

与会人信息会展示在日程详情中,帮助用户确认参会人员。对于会议应用来说,service.uri 中的 DeepLink 通常会携带会议室 ID 等参数,用户点击"加入会议"后直接进入对应的会议房间。


五、其他场景速览

前面详细讲了出行和会议两个场景的完整实现,其他场景的代码结构完全一样,差异只在字段内容的填写上。这里简要列出几个场景的要点,帮你快速对照:

酒店住宿:适合设置为全天日程(isAllDay: true),标题包含酒店名称和地址,别忘了填 location 字段(包含经纬度),ServiceType 用 TRIP。提醒建议设前一天上午 9 点(reminderTime: [1440])和当天上午 9 点(reminderTime: [0])。

还款提醒:也是全天日程,毕竟还款日是以"天"为单位的。备注里写上待还款金额,ServiceType 用 REPAYMENT,提醒一次就够了——当天上午 9 点(reminderTime: [0])。

直播 / 抢购 / 课程 / 赛事 / 运动:都是精确到具体时刻的非全天日程,提醒时间一般设准时和开始前 10-30 分钟。区别就是选对 ServiceType,按钮文案就会自动匹配。


六、总结与实践建议

日历服务的接入逻辑并不复杂——创建账户、配置日程、写入系统。但要把体验做好,有几个细节值得注意:

  1. 标题要有信息量。用户在桌面卡片上看到的可能只有标题,所以"G107 上海虹桥-北京南"远比"火车票行程"有用。
  2. 提醒时间要合理。出行类提前 2-4 小时,会议和课程提前 10-15 分钟,全天日程用上午 9 点。不要设过多提醒,免得用户觉得被打扰。
  3. 及时更新和清理。行程改签了就更新日程,退票了就删除。不要让过期或无效的日程留在用户的日历里,这会损害用户对应用的信任。
  4. displayName 要用应用真名。用户看到一条日程时,会通过日历账户名称判断"这是哪个应用写的"。用正式的应用名称,而不是内部代号或缩写。
  5. DeepLink 要能真正落地。一键服务按钮点下去后跳转的链接,必须能正确打开应用的对应页面。如果链接失效或跳错位置,这个功能反而会让用户感到困惑。

日历是一个天然的时间管理入口,用户每天都会看。把应用中有价值的时间事件写进去,既帮用户管理好了日程,也为应用争取到了在系统级入口的露出机会。

Cocinare酷世乐与世界咖啡冠军井崎英典达成长期技术共创

近日,Cocinare在深圳举办2026春季发布会,官宣中文品牌“酷世乐”,并与世界咖啡冠军井崎英典达成长期技术共创,其将担任首席全球咖啡大师参与研发。发布会还全球首发咖啡校准体系Master-Calibrated Standards™,并亮相含首款可视化温控壶的新品矩阵,品牌将持续推进“让好喝,成为标准”的发展方向。

四川路桥:下属企业中标南充至广安铁路60.69亿元项目并参与设立合伙企业

36氪获悉,四川路桥公告,公司下属企业近日收到中标通知书,成为新建南充至广安铁路站前工程NGZQSG-02及NGZQSG-05标段中标人,中标价合计约60.69亿元。为取得该项目施工份额,公司下属企业与其它合伙人共同签订合伙协议,以认购有限合伙企业份额模式参与投标,共认缴约6.47亿元份额。该合伙企业存续期为7年,主要风险包括本金亏损及施工利润不达预期等。

谷歌将安卓转向闭源:没有苹果命,得了苹果病

对于经历过 2007-2017 年移动操作系统大洗牌时代的用户,Android 曾是一面旗帜。

在 BBOS、塞班、Windows Mobile「百家争鸣」之后,由谷歌牵头的 Android 成为仅存的硕果。这个开源操作系统,曾经是包容、多样与打破陈规的象征,与隔壁精致、有板有眼,却封闭的 iOS 形成了鲜明对比。

▲ 图|WIRED

然而多年以后,谷歌变了,Android 也变了。

2026 年 4 月 1 日,本来是个大家都开心的日子,谷歌突然宣布:

Android 开发者验证功能自近日起全面推出,所有开发者必须向谷歌注册自己的应用。未来如果系统检测到安装包没有注册信息,将会强制启动 7 天的「安装冷静期」,或者要求用户必须接上电脑用 ADB 授权安装。

这套名为「高级侧载」(advanced sideload)的功能预计在今年 8 月向所有安卓用户推出,「冷静期」功能则计划 2027 年在全球范围施行。

本质上,谷歌封锁了「侧载」应用,即直接通过 .apk 文件直装应用的路子。

▲ 图|Aurich Lawson

从不断收紧的第三方应用侧载限制,到针对全球开发者的强制认证要求,再到令人难绷的「七天安装冷静期」,叠加前面转向闭源的新闻——无不在释放一个令人心寒的信号:

谷歌正在亲手杀掉曾经由它开启的开放 OS 时代。

毕竟如果我们回看 Android 诞生初期,它之所以能够击溃塞班、抗衡 iOS,靠的绝不是单纯「免费」,同时还有那份近乎野蛮的开放性。

▲ 图|TechRadar

正是对于「开放性」的包容,让 Android 成功突围,得以在全球移动 OS 市场上和苹果抗衡,以后来居上的姿势告诉全世界:

手机操作系统,何必是被严密限制的黑盒?它完全可以是一张白纸,由厂商、开发者、用户共同书写——

如果你不喜欢厂商预装的桌面,就直接换一个启动器;Play Store 也不是唯一的下载渠道,各种第三方应用市场百花齐放。

当然,如果你想要的应用没有上架应用商城,完全可以通过侧载的方式安装。

▲ 知名第三方应用市场 F-Droid|Android Authority

这种高自由度和可定制化,就是 Android 森林能够如此茂盛的根基,也是它相比坚持「封闭花园」策略的 iOS 的最大竞争优势。

但今天的谷歌,似乎早已忘记了当年「不作恶」(Don’t be evil)的口号,同时患上了某种「权力焦虑症」——它要控制系统里每一条 ADB 指令的流向,质询每个安装包的来源。

这完全就是「屠龙勇士终成恶龙」的现实版本。

谷歌正在用一种温水煮青蛙的方式,将 Android 变成一个披着「开源」的皮、实则高度集权的私家领地。

▲ 图|How-to-geek

在谈到这些限制时,谷歌永远都只会抛出「安全」和「用户体验」的理由。

诚然,移动安全至关重要,在如今这个手机承载了我们 95% 日常生活的时代来说更是如此,但我们已经经历了太多太多血淋淋的教训——

所有拿「安全」做挡箭牌的商业决策,最后都变成了攫取用户信息获利的手段。

更何况,谷歌在不管不顾地推进这种封闭化进程时,反而让自己露出了一个极其尴尬的姿势。

「没有苹果命,得了苹果病。」这句话以前是形容盲目抄袭苹果产品形态、却忽视了苹果设计逻辑的手机厂商用的,现在用来形容谷歌倒是再贴切不过——

没有人能够否认,iPhone + iOS 软硬件结合的「围墙花园」(walled garden)模式在商业上的成功,iPhone 用户也乐于留在其中。

▲ 图|Apple

但我们必须清楚:苹果能够跑通这一套逻辑的前提,是它真的做到了「为用户提供一个完整的闭环生态」。

iPhone 只能从 App Store 获取应用不假,但 App Store 里的应用不仅会经过苹果严苛的技术审核,更重要的是:苹果的服务足够便捷、稳定,且能够在全球范围提供相对统一的高质量内容。

▲ 图|Apple

反观谷歌 Play Store,光是 Play Store 本身的 Bug 频出和应用质量的良莠不齐,就足以让谷歌梦里的「围墙花园」地基垮掉。

虽然 App Store 里面也有不少粗制滥造的东西,但相比 Play Store 的生态还是小巫见大巫了。

▲ Play Store 付费榜

哪怕谷歌已经扯着嗓子喊了几年的 Play Protect 机制和强制 API 规定,我们依然能在 Play Store 的推荐页乃至首页,看到大量粗制滥造的马甲包。

甚至在 2026 年的今天,Play Store 免费区里面依然潜伏着太多字面意义上的「毒瘤」应用。

▲ 麦卡菲安全通报的数个 Play Store 毒瘤应用|McAfee

而在这种官方渠道无法做到尽善尽美的情况下,谷歌却还要斩断用户寻找第三方替代方案的后路,好一个又当又立的典范。

别忘了,Play Store 同样是锁区的,并且锁区机制相比 App Store 简直有过之而无不及。

对于很多地区的用户来说,侧载 APK 其实跟所谓的极客精神八杆子打不着——而是为了在官方商店堵死的情况下,维持手机的基本功能。

▲ 图|Reddit

而谷歌紧赶慢赶想要关上侧载这扇门,本质上就是在强迫用户接受一个德不配位的商店服务。

谷歌对 Android 核心资产的态度转变,更是让人加深对这个操作系统的不信任。

从去年开始,谷歌就开始宣布 Android 的部分核心功能不再向 AOSP 开放,而是整合进其私有的 GMS 服务中。

换言之,以前「原生」和「类原生」的区别,已经被谷歌自己切割成「Pixel OS」和「其它」。

同时,AOSP 源代码的公开频率和深度也在大幅削减,直至谷歌最后宣布:仅会向部分生产 Android 手机的企业伙伴提供 AOSP 源码。小型硬件品牌、第三方 ROM 和个人开发者,哪凉快哪歇着去吧。

▲ 图|Android Authority

这就是 Android 从开源转向闭源的标志。

那个曾经属于全球开发者的 Android、那个曾经用「农村包围城市」战术赶超 iOS 的 Android,其开放与自由的属性,正在被谷歌从内部一点点掏空。

自从 Pixel 手机的业务站稳脚跟,谷歌就开始试图通过控制底层代码和基础 API,把 Android 从一个公共资源池,转型为纯粹的、为谷歌搜索、广告、应用生态业务服务的赚钱工具。

▲ 图|Google Ads

这种商业上的贪婪,正是最近几代 Android 大版本在审美和质量上表现得极其分裂的原因——

它既想要苹果那样对软硬件生态的话语权,又舍不得放下它那套依靠大规模数据采集和广谱分发的商业逻辑。

这其中最典型的例子,就是谷歌对 UI 设计规范的反复无常。

你或许还记得 Material Design,那个谷歌曾经提出的介于拟物化和扁平化之间的、以「折纸」为哲学的设计语言:

▲ 图|Tech & ALL

从 Material Design 1.0 到现在的 Material You、Material 3 Expressive,且不说安卓本身的设计语言有多割裂,谷歌做了这么多年设计先锋,到头来却连自家全家桶的设计都没办法统一跟上最新标准。

自家房间都扫不干净,谷歌又有什么颜面指挥全世界 Android 开发者呢?

▲ Material 3 Expressive 效果图|Google Design

理念的混乱、审美的平庸,正是谷歌无法建立起类似苹果那样的品牌信仰和生态凝聚力的原因。

然而在这种软实力缺失的前提下,谷歌却试图通过强硬的硬性限制——比如可能在 2027 年全球上线的「七天安装冷静期」——来建立防御壁垒。

这种做法不仅是逃避责任,更是一种技术上无能的表现。

▲ 图|Interesting Engineering

如果一个系统需要通过人为制造障碍、折磨用户耐心的方式,来规劝用户应该做什么,维持所谓的安全,那只能说明这个系统的底层架构已经混乱到了无法通过正常技术手段解决问题的地步。

苹果之所以是苹果,因为它从卖 iPhone 的第一天就设计好了如何运转这样一个封闭的生态系统。

谷歌想要变成苹果,用的方法却是「头疼砍头、脚痛砍脚」

从 18 年前的 Android 1.0 走到今天,谷歌似乎忘记了 Android 之所以能有如今的地位,正因为它是一个「和 iOS 不一样的选择」。

▲ 图|Android Police

然而当谷歌把 Android 变得越来越像一个廉价、粗糙且充满限制的 iOS 仿制品时,它就失去了自己最核心的竞争优势——

既然非得从两个封闭系统里选一个,那我凭什么选尾大不掉的 Android,而不去买更完善、更安全、封闭得井井有条的 iPhone 呢?

「开放」与「封闭」的矛盾中心,就是谷歌没有办法拿出一个真正能够在封闭系统内运行的足够好的 Android 产品,来为自己的策略撑腰。

它既没有苹果那样端到端的生产研发实力,又没有苹果的品位和审美,更是至今保留着 Android 里面抠都抠不掉的牛皮癣——年代断层的 UI、无法统一的 API、以及封闭又稀碎的软件生态。

苹果给用户喂饭,虽然不一定合每个人的胃口,但好歹是饭。

而谷歌喂的,就很难说是什么东西了。

▲ 图|9to5Google

直白地说,谷歌对于 Android 开放性动手动脚,本质就是缺乏远见且充满傲慢的体现,在自己的地位稳固后,开始逐渐剥离曾经赋予它权力的「草根」生态,转而渴望独裁的横征暴敛。

如果谷歌继续在封闭之路上狂奔,那它最终收获的不会是一个稳定的赚钱机器,而只会是一个死气沉沉的荒芜花园。

因为谷歌在 18 年之后,已经彻底忘记了:

人们选择 Android,难道是因为它像 iOS?

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

宇通客车:3月生产4201辆,同比下降12.41%

36氪获悉,宇通客车公告,2026年3月生产量4201辆,同比下降12.41%,其中大型1976辆、中型1183辆、轻型1042辆;销售量4118辆,同比下降15.79%,大型1859辆、中型1157辆、轻型1102辆。本年累计生产量8429辆,同比下降5.65%,销售量7652辆,同比下降15.08%。

鸿蒙碰一碰分享:手机轻碰,内容就过去了

引言

跨设备传内容这件事,理想状态是什么?大概就是——我手机上有个东西想给你,碰一下就过去了,不用加好友、不用扫码、不用等配对。

鸿蒙 Share Kit 的碰一碰分享做的就是这件事。两台手机轻碰一下,图片、链接、Wi-Fi 信息就传过去了;手机往 PC 屏幕上一放,文件就到了电脑里。整个过程没有中间步骤,靠的是设备间物理接触触发的分享机制。

本文面向希望为应用接入碰一碰分享能力的鸿蒙开发者,从手机间分享和手机与 PC/2in1 间分享两个维度,梳理这项能力的工作机制、卡片设计要点、异常处理策略,以及完整的开发流程。


一、手机与手机之间的碰一碰分享

1.1 基本流程

碰一碰分享的业务流程可以用四步概括:

  1. 注册:应用在可分享的页面注册碰一碰事件(knockShare)。
  2. 触发:用户将手机与对端设备轻碰,系统发现设备后触发回调。
  3. 发送:应用在回调中构造分享数据并发送。
  4. 清理:离开可分享页面时,解除事件注册。

使用前有几个前提条件:双端设备都要亮屏且解锁,华为分享服务需要处于开启状态(系统默认开启)。如果用户手动关闭了华为分享服务,轻碰时会收到系统通知提示开启。

还有一点需要了解:宿主应用无法直接获知分享结果。对端是接收了还是拒绝了,Share Kit 会通过系统通知告知用户,而不是通过回调返回给应用。如果任意一端设备不支持碰一碰能力,轻碰则完全没有响应。

环境要求方面,手机系统需要 HarmonyOS NEXT Release 及以上版本。可以用 canIUse 做运行时判断:

if (canIUse('SystemCapability.Collaboration.HarmonyShare')) {
  // 支持碰一碰分享
}

1.2 设备间的信任与安全

从 HarmonyOS NEXT 5.0.0.123 SP16 开始,碰一碰分享在发送端和接收端都会展示对方的身份信息,帮助用户确认"我在和谁传东西":

  • 如果对端已登录华为账号,会展示对方的账号昵称和头像
  • 如果对端未登录华为账号,则展示设备信息

需要注意的是,如果发送端的系统版本低于 SP16,接收端将不会展示任何发送方信息。


二、分享卡片的设计:不只是技术问题

碰一碰触发后,对端设备会收到一张分享卡片。卡片的样式直接影响用户是否愿意接收,所以这部分值得认真对待。

2.1 三种卡片模板

Share Kit 根据你传入的字段组合,自动匹配不同的卡片模板:

纯图片布局——只有预览图,没有标题和描述。适合分享文件、图片等不需要文字说明的场景。构造分享数据时只传 thumbnailUri 即可触发这种布局。预览图支持最小宽高比 1:4,超出部分会被裁剪。

沉浸式大卡布局——预览图 + 标题 + 描述 + 应用图标,视觉冲击力最强。适合分享链接类内容。触发条件是同时传入 titledescriptionthumbnailUri,且预览图宽高比小于 1:1(即竖图)。标题最多显示 2 行,描述 1 行,超出部分以省略号截断。如果标题末尾有重要信息,建议控制在 20 个中文字符左右。

白卡上下布局——同样包含预览图、标题、描述和应用图标,但预览图只显示在卡片上方,不会铺满整张卡片。触发条件和沉浸式大卡一样,区别在于预览图宽高比大于 1:1(即横图)。

应用图标不需要额外配置,系统会自动获取。

2.2 预览图的质量建议

预览图太大会拖慢加载速度,太小则显示模糊。建议参考以下标准:

预览图来源 推荐比例 推荐分辨率
应用创作的海报 3:4 最小 600×800,最大 3000×4000
用户上传的图片 不限制 最大 3000×4000

2.3 预览图来不及下载怎么办

一个很实际的问题:如果应用使用的是云端存储的图片作为预览图,碰一碰回调触发时图片可能还没下载到本地,这就会导致超时失败。

Share Kit 对此提供了预览图延迟更新的能力。思路很简单——先发核心数据,建立连接,系统会用默认预览图填充卡片;等云端图片下载完成后,再调用 sharableTarget.updateShareData 更新预览图:

harmonyShare.on('knockShare', capabilityRegistry, (sharableTarget) => {
  // 先发送核心数据,不带预览图
  let shareData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    title: '碰一碰分享卡片标题',
    description: '碰一碰分享卡片描述'
  });
  sharableTarget.share(shareData);

  // 图片下载完成后更新预览图
  setTimeout(() => {
    let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';
    sharableTarget.updateShareData({
      thumbnailUri: fileUri.getUriFromPath(filePath)
    });
  }, 5000);
});

这样用户不会因为预览图加载慢而等待,分享体验更流畅。


三、用户引导:让用户知道"这里可以碰"

碰一碰是一个相对新的交互方式,很多用户可能不知道当前页面支持这个功能。给出适当的引导可以有效提升分享意愿。

Share Kit 推荐两种引导方式:

  • 文本提示:在页面上展示"可碰一碰分享至 HarmonyOS 5 及以上版本手机"的文案。
  • 动图提示:用动画展示碰一碰的操作方式,更直观。

Share Kit 提供了统一的动图资源文件。下载后将 knock_share_guide 目录下的所有文件放到应用的 entry/src/main/resources/rawfile 目录即可使用。


四、核心开发流程:注册、发送、清理

4.1 注册与取消碰一碰事件

注册碰一碰事件有两种方式。简单场景下,直接传入回调函数即可:

private immersiveListening() {
  harmonyShare.on('knockShare', this.immersiveCallback);
}

private immersiveDisablingListening() {
  harmonyShare.off('knockShare', this.immersiveCallback);
}

如果需要更精细的控制(比如指定窗口、声明单向发送能力),可以传入 SendCapabilityRegistry 配置:

let capabilityRegistry: harmonyShare.SendCapabilityRegistry = {
  windowId: 999,  // 替换为实际的 windowId
};
harmonyShare.on('knockShare', capabilityRegistry, callback);

和隔空传送一样,生命周期管理是关键。进入可分享页面时注册,离开时(包括退后台)必须取消:

aboutToAppear(): void {
  this.immersiveListening();
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.on('onBackGround', this.onBackGround);
}

aboutToDisappear(): void {
  this.immersiveDisablingListening();
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.off('onBackGround', this.onBackGround);
}

onPageHide(): void {
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.emit('onBackGround');
}

private onBackGround = () => {
  this.immersiveDisablingListening();
}

4.2 构造分享数据并发送

在碰一碰回调中构造分享数据。链接类分享的 utd 类型需要设置为 HYPERLINK

private immersiveCallback = (sharableTarget: harmonyShare.SharableTarget) => {
  let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';
  let shareData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    thumbnailUri: fileUri.getUriFromPath(filePath),
    title: '碰一碰分享卡片标题',
    description: '碰一碰分享卡片描述'
  });
  sharableTarget.share(shareData);
}

4.3 通过 App Linking 实现直达应用

分享链接时,强烈建议使用 App Linking 而不是普通 URL。App Linking 的好处在于:

  • 应用已安装:直接拉起应用对应页面。
  • 应用未安装:默认通过浏览器打开网页;配合 App Linking Kit 的直达应用市场能力,可以直接跳转到应用市场引导安装。再结合延迟链接能力,用户安装完成后首次打开应用时,仍能获取之前分享的链接内容——这对转化率的提升非常有价值。

另一种方案是 Deep Linking,但它只在本地已安装的应用中查找匹配项,未安装时会提示"暂无可用打开方式"。

4.4 异常场景的处理

碰一碰触发后,并不总是能顺利完成分享。Share Kit 提供了两种异常处理方式,帮助开发者优雅地终止分享,避免用户干等:

当前界面无可分享内容(6.0.2(22) 版本起支持):

sharableTarget.clarifyNonShare({ 
  message: '请在支持碰一碰分享的界面再试' 
});

这会终止本次分享,并弹出提示引导用户去可分享的页面。

网络或业务原因导致分享失败(5.0.3(15) 版本起支持):

sharableTarget.reject(harmonyShare.SharableErrorCode.DOWNLOAD_ERROR);

这会终止分享并提示用户具体原因。


五、邀请组队:碰一碰的另一种玩法

除了内容分享,碰一碰还有一个很有意思的应用场景——邀请组队。比如游戏中邀请旁边的朋友加入房间,碰一下手机就完成了。

这个场景有一个特殊问题:如果双方都在组队房间里互碰,会导致互相邀请加入对方房间的冲突。Share Kit 对此提供了单向仅发送能力,通过在注册时设置 sendOnly: true 来声明:

let capabilityRegistry: harmonyShare.SendCapabilityRegistry = {
  windowId: 999,
  sendOnly: true,  // 声明仅支持单向发送
};
harmonyShare.on('knockShare', capabilityRegistry, callback);

当碰一碰的双方都设置了 sendOnly,系统会终止本次分享并提示"请任意一方退出当前应用后再试"。只要有一方没设置 sendOnly,分享就能正常完成。

对端应用被拉起后,通过 onCreateonNewWant 回调中的 want.uri 获取组队链接,解析其中的参数来处理组队逻辑:

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  console.log('收到组队链接: ', want.uri);
  // 解析链接参数,处理组队邀请
}

onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  console.log('收到组队链接: ', want.uri);
  // 应用已在前台时的处理
}

六、手机与 PC/2in1 之间的碰一碰分享

碰一碰不只是手机之间的事。手机和 PC/2in1 设备之间也可以碰一碰分享,而且交互方式更有趣——手机直接往屏幕上一放,利用 PC/2in1 的屏幕感知能力识别碰触动作和位置,实现窗口级的精准交互。

6.1 谁发谁收的规则

从 6.0.0(20) Beta5 版本开始,手机与 PC/2in1 之间不支持双向分享,遵循明确的优先级:

  1. 手机前台有可分享内容 → 手机发送,PC/2in1 接收。
  2. 手机前台无可分享内容,PC/2in1 前台窗口有 → PC/2in1 发送,手机接收。
  3. 双方前台都没有可分享内容 → 走无内容分享逻辑。

更早的版本(6.0.0(20) Beta3 及之前)支持双向同时分享,但后续版本取消了这个行为。

6.2 物理姿态要求

手机碰 PC/2in1 屏幕时,对放置姿态有具体要求:

  • 俯视夹角 ≤ 5°(手机要基本平放在屏幕上)
  • 侧视夹角 > 35°
  • 正视夹角 ≤ 25°
  • 手机不能超出屏幕边缘

此外,支持官方手机保护壳,但过厚的外壳可能影响感知。仅支持直板手机或折叠手机的直板形态。双端设备需要登录相同的华为账号。

6.3 沙箱接收:文件直达应用

从 6.0.0(20) 版本开始,PC/2in1 设备支持沙箱接收能力——手机碰一下屏幕,文件直接传入 PC/2in1 应用的沙箱目录,传完后通知应用处理,无需用户手动操作。

应用需要声明自己支持接收的文件类型和最大数量。如果类型不匹配,系统会回退到华为分享的默认接收逻辑;如果数量不匹配,会弹窗提示用户。

注册沙箱接收事件:

aboutToAppear(): void {
  let capabilityRegistry: harmonyShare.RecvCapabilityRegistry = {
    windowId: 999,
    capabilities: [{
      utd: utd.UniformDataType.IMAGE,
      maxSupportedCount: 1,
    }]
  };

  harmonyShare.on('dataReceive', capabilityRegistry, 
    (receivableTarget: harmonyShare.ReceivableTarget) => {
      let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
      receivableTarget.receive(context.filesDir, {
        onDataReceived: (sharedData: systemShare.SharedData) => {
          let records = sharedData.getRecords();
          records.forEach((record) => {
            // 处理接收到的文件
          });
        },
        onResult(resultCode: harmonyShare.ShareResultCode) {
          if (resultCode === harmonyShare.ShareResultCode.SHARE_SUCCESS) {
            // 接收成功
          }
        }
      });
    }
  );
}

离开页面时同样要解除注册。如果因为业务原因需要拒绝本次接收,可以调用 receivableTarget.reject()


七、总结

碰一碰分享覆盖了两大场景:手机与手机之间的内容传输和组队邀请,以及手机与 PC/2in1 之间的文件传输。对开发者来说,接入时有几个核心关注点:

  1. 生命周期管理是基本功。注册和取消事件必须与页面生命周期严格对应,退后台也要取消,否则会出现意料之外的行为。

  2. 卡片设计影响转化。三种卡片模板由字段组合和图片比例自动决定,了解触发规则后有意识地选择合适的布局,预览图质量要控制在合理范围内。

  3. 异常处理不能省。无内容可分享时用 clarifyNonShare 引导用户,网络异常时用 reject 终止等待。这些细节决定了用户在非理想状态下的体验。

  4. App Linking 是最佳搭档。结合延迟链接和直达应用市场能力,即使对端没有安装应用,也能完成从分享到安装到打开内容的完整链路。

  5. 手机碰 PC/2in1 的姿态要求值得在产品引导中告知用户,避免"碰了没反应"的困惑。沙箱接收能力则为 PC 端应用打开了一种高效的文件接收方式。

华海药业:美沙拉秦肠溶片获药品注册证书

36氪获悉,华海药业公告,公司近日收到国家药监局核准签发的美沙拉秦肠溶片《药品注册证书》。该药品规格为0.5g,主要用于溃疡性结肠炎及克罗恩病的治疗。根据米内网数据预测,该产品2025年国内市场规模约7.87亿元。截至目前,公司在该项目上已投入研发费用约1243万元。此次获批可视同通过一致性评价,有助于丰富公司产品线并提升市场竞争力。

使用Compose Navigation3进行屏幕适配

这篇文章将介绍B站是怎么使用 Compose Navigation3 进行页面的宽屏适配,并解决其中遇到的问题的。

本文所涉及到的 Compose 页面均已完成了 CMP 跨平台化适配,内容中基于安卓习惯所提的 “Activity” 如无额外说明均代表各平台的页面容器,即可以直接替换为iOS的UIViewController理解。

Navigation3 简介

Navigation3 是 Google 在 2025 年推出的全新 Compose 导航库,与之前的 Navigation Compose 有本质区别。它不再内置导航图(NavGraph)和 NavHost,而是将导航栈的管理权完全交给开发者,框架只负责"根据栈内容渲染 UI",将 f(data)=UI 的理念扩展到了页面导航栈上。得益于这新的精简的框架概念,使得 Navigation3 能很轻松地跟现有大型app的路由系统搭配整合使用,不像之前的 Navigation 库那样需要将现有路由完全迁移到导航图(NavGraph)声明上。开发者完全可以在单模块内进行 Nav3 的接入使用,同时保持整体的路由声明方式。

虽然使用Compose编写的页面,因其声明式的特性,已经有良好的响应屏幕宽度变化的能力。但是近期出现的超宽、折叠屏手机,包括鸿蒙平台的平板、桌面等设备,会让仅支持响应式布局的页面在超宽显示模式下给用户带来不好的视觉和交互体验。与Navigation3 库同时提出的“WindowSizeClass”中,将屏幕根据宽度划分为小、中、大等各个档位。这种“断点式”的屏幕划分可以指导我们知道在怎么样的情况下将应用的界面显示编排成全屏页面还是分屏页面的形式,显著提高在折叠屏、平板、桌面等“非传统手机”屏幕下的用户呈现能力。

为什么需要纯Compose的导航框架

在b站深入推进业务 CMP 跨平台化的过程中,我们发现缺少一个适配 CMP 属性的页面导航框架是深入业务使用的一大阻碍。

在先前的页面方案中,我们仿照安卓原生实现,选择了为每个导航节点嵌套一个原生window容器,即每打开一个个 Composable 页面都对应一个安卓 Activity 、iOS UIViewController 和 鸿蒙 entry 的创建与展现。这个方案能让我们快速地将 Compose 页面集成到现有工程中,但随后带来了更多其他的问题。

首要面临的问题是内存压力。在 iOS 和鸿蒙中,每打开一个新的原生容器来承载Compose页面,都意味着一个 CAMetalLayer/NativeWindow 被创建,对应3倍大小的render buffer也会被创建在内存中,内存占用就会相应提升。根据我们测算,使用三缓冲区渲染的 iOS,每一个 CAMetalLayer 都会占用约40M的内存。随着接入Compose的页面越来越多、用户打开的页面越来越多,内存压力会不断增长,影响我们的CMP推进进程。

另一个问题是,Compose 上下文内使用的 Lifecycle 系统是基于安卓生命周期概念设计的,在 iOS 和鸿蒙系统中多少有些水土不服,需要 ComposeView 的宿主层进行额外的配置工作,例如将 UIViewController 的 willAppear didAppear等回调桥接到 androidx 生命周期的相应事件上。在“标准容器”无法满足页面展现需求,需要做业务定制的时候,这些额外配置将成为开发过程的摩擦,在接入者不熟悉/没有意识到需要做这些配置的时候,将严重拖慢review和交付进度。

并且,这样的桥接总会丢失准确信息,特别是在页面切换的时候,总会错过准确的生命周期回调,导致在后台执行了额外的工作,引起卡顿、发热等问题;

同时,不正确的生命周期事件会让开发有不正确的预期,这一点会在本文后面详细描述。

基于以上问题的考量,我们得出结论,至少在纯Compose世界内的页面导航切换范围内,我们需要一个纯Compose的导航框架。而刚刚推出正式版、其结构思想契合现代代码开发思路的Navigation3成为我们的首选方案。

路由与导航的区别和联系

在之前的开发理念中,我们往往将"路由"和"导航"混为一谈:一个 URI 既是页面的标识,也是跳转的触发方式。我们将URI标注在一个 Fragment/Activity 上之后,调用“路由”跳转这个URI将直接打开这个页面实例。

@Route("bilibili://some/page")
class SomePageActivity: Activity()

Router.routeTo("bilibili://some/page") // == startActivity(SomePageActivity.class)

然而,在后续的开发和迭代过程中,我们逐渐意识到,这一次跳转动作应当分为两个具体步骤:使用“路由”寻找这个 URI 对应的页面信息,然后使用“导航”组件将这个页面展现在用户面前。

在我们的项目 CMP 化推进过程中,基架团队已经将这个理念应用到了b站的 CMP 版路由组件中,允许业务方在复用公共路由表的查找逻辑和结果的前提下,根据不同页面需要自定义自己的“路由结果导航”行为,为这次的 Nav3 快速接入提供了合适切入点。

数据驱动的声明式导航栈展现

Navigation3 的核心理念是:导航栈就是一个普通的 List,UI 是这个 list 的函数。

class MyBackStack<K : NavKey>(private val list: SnapshotStateList<K>) : SnapshotStateList<K> by list {
    override fun add(item: K){
        // 可以在这里提前处理冲突元素的清理
        list.add(item)
    }
}

@Composable
fun NavPage(modifier: Modifier = Modifier){
    val backStack = remember {
        mutableStateListOf(HomeNavKey)
    }

    NavDisplay(
        backStack = backStack,           // 数据:当前栈内容
        sceneStrategy = ...,             // 策略:如何将栈内容映射为布局
        entryDecorators = listOf(...),   // 装饰器:为每个 entry 注入能力
        entryProvider = entryProvider {   // 注册:NavKey → Composable 的映射
            entry<SomeNavKey> { key -> SomePage(key) }
        },
    )
}

开发者只需要按照自己的页面逻辑操作 backStack ,例如添加、移除,或者“在特定页面入栈时清除其他页面”用来实现“最多只有一个详情页被打开”的情况。NavDisplay 会自动响应变化并重新计算布局。不需要手动调用 navigate()、popBackStack() 等命令式 API,更加贴合 Compose 生态中的开发习惯。

在实际业务中接入使用

在实际的业务场景中使用 Navigation3 ,当然不像其他网络示例那样简单调用。我们将需要深入使用 Nav3 库提供的各种 api ,定制自己的业务功能。

NavKey 与路由发现

在 Nav3 中,NavKey 是描述页面的最小独立元素,每一个 NavKey 类型都跟一个页面绑定,描述了期望被打开的页面的基础信息,例如请求这个页面所需的唯一ID:

@Serializable
@Route("bilibili://some/nav3/page/with/id/{id}")
data class SomeIdPageNavKey(
    val id: String,
    val paramFromQuery: String,
) : NavKey

@Route("bilibili://some/page/with/id/{id}")
@Composable
fun SomePage(id: String, modifier: Modifier = Modifier, paramFromQuery: String = ""){}

因为一个 NavKey 可以跟一个路由严格对应,所以以上这段声明代码完全可以交给路由的 KSP 处理器自动生成。在子页面发起正常的路由跳转请求时,通过拦截器模式拦截此次路由的查找过程,如果找到匹配的 NavKey 类型,则将一个实例添加到backStack栈顶,将普通的导航行为桥接到 Nav3 的导航中。


// 路由拦截器:将普通路由请求桥接到 Nav3 的 backStack
class Nav3RouteInterceptor<KEY : NavKey>(
    private val onNavKeyFound: (KEY) -> Boolean,
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originUri = chain.uri
        // 将原始 URI 转换为 Nav3 专用的查找格式
        // 例如 bilibili://some/page/123 → bilibili://some/nav3/page/123
        val navUri = convertToNav3Uri(originUri) ?: return chain.proceed()

        // 在路由表中查找这个 URI 对应的 NavKey 工厂函数
        val target = chain.find(navUri) as? SomeIdPageNavKey

        returnif (key != null && onNavKeyFound(key)) {
            Response.Done  // 拦截成功,阻止后续的默认导航行为(如 startActivity)
        } else {
            chain.proceed() // 未匹配,交给下一个拦截器或默认处理
        }
    }
}

// 在 Nav3 宿主页面中组装拦截器
@Composable
fun Nav3HostPage() {
    val backStack = remember { mutableStateListOf<MyNavKey>(HomeNavKey) }

    // 创建拦截器,拦截成功时将 NavKey 推入栈
    val interceptor = remember {
        Nav3RouteInterceptor<MyNavKey> { key ->
            backStack.add(key)
        }
    }
    // 在原有 Router 上叠加拦截器,生成新的 Router 实例
    val localRouter = LocalRouter.current
    val nav3Router = remember(localRouter, interceptor) {
        localRouter.newBuilder().addInterceptor(interceptor).build()
    }

    NavDisplay(
        backStack = backStack,
        entryDecorators = listOf(
            // 通过 Decorator 将带拦截器的 Router 注入到所有子页面
            // 这样子页面内发起的路由请求也会经过拦截器
            remember { Nav3RouterDecorator(nav3Router) },
            ...
        ),
        ...
    )
}

// Decorator 实现:通过 CompositionLocal 注入 Router
class Nav3RouterDecorator(
    private val router: Router,
) : NavEntryDecorator<MyNavKey>(
    onPop = {},
    decorate = { entry ->
        CompositionLocalProvider(LocalRouter provides router) {
            entry.Content()
        }
    },
)

NavKey 需要支持序列化(用于 backStack 的保存/恢复),因此都标注了 @Serializable;当然,也可以选择统一保存原始跳转链接的string内容,在需要恢复时重新走一次路由查找。

NavKey 与 entry 注册

NavKey 仅能表示“有一个页面”,在 Nav3 中,还需要通过 entryProvider 的方式将“这个 NavKey 对应的页面如何显示” 注册到当前的 NavDisplay 中:

NavDisplay(
    backStack = backStack,
    entryProvider = entryProvider {
        entry<SomeIdPageNavKey>(metadata = BiliListDetailSceneStrategy.detailPane()) { key -> SomePage(key.id, Modifier, key.paramFromQuery) }
    },
 )

当然,这一段注册代码也可以抽象为 EntryProviderScope.() -> Unit 的函数,由路由 KSP 处理器统一生成,页面只需要按需注册即可。

SceneStrategy 与 Scene

SceneStrategy 是 Navigation3 中最关键的扩展点。它接收当前 backStack 中所有 entry,返回一个 Scene 来描述如何布局。

在我们的宽屏适配实践中,我们实现了 BiliDetailSceneStrategy:

class BiliDetailSceneStrategy<K : NavKey>(
    val windowSizeClass: WindowSizeClass,
) : SceneStrategy<K> {
    override fun SceneStrategyScope<K>.calculateScene(entries: List<NavEntry<K>>): Scene<K>? {
        if (windowSizeClass.isAtLeastMedium(...)) {
            // 宽屏:从栈中找到最后一个 List entry 和最后一个 Detail entry
            val listEntry = entries.findLast { it.metadata.containsKey(LIST_KEY) }
                ?: return null
            val detailEntry = entries.findLast {
                it.metadata.containsKey(DETAIL_KEY)
            }
            return BiliListDetailScene(
                listEntry = listEntry,
                detailEntry = detailEntry,
                listWidth = if (windowSizeClass.widthLargeCompat()) 375.dp else300.dp,
            )
        }
        return null  // 非宽屏的情况:返回 null,表示当前 Strategy 不处理这个情况,NavDisplay 将使用默认单页 Strategy
    }
}

BiliListDetailScene 的布局结构:

Row(modifier = Modifier.fillMaxSize()) {
    // 左栏:列表
    Box(modifier = Modifier.width(listWidth)) {
        listEntry.Content()
    }
    VerticalDivider(...)
    // 右栏:详情或占位图
    Box(modifier = Modifier.weight(1f)) {
        if (detailEntry != null) {
            CompositionLocalProvider(LocalBackIconVisibility provides false) {
                detailEntry.Content()
            }
        } else {
            DefaultDetailPlaceholder()
        }
    }
}

每个 entry 通过 metadata 标记自己属于哪个区域。metadata 在注册 entry 时通过 BiliListDetailSceneStrategy.listPane() / detailPane() 设置:

companion object {
    fun listPane()= mapOf("BiliListDetailScene-List" to true)
    fun detailPane()= mapOf("BiliListDetailScene-Detail" to true)
}

SceneStrategy 的 calculateScene 在每次 backStack 变化时都会被调用。如果设备发生折叠/展开,windowSizeClass 变化会触发 BiliListDetailSceneStrategy 的重建(通过 remember(windowSizeClass)),从而自动切换单栏/双栏布局。

踩过的一些坑

从原生导航模式迁移到 Nav3 ,页面的导航方式将发生重大变化,其中有不少在往常开发过程中注意不到的地方。

生命周期、页面重入、状态保存

首先最大的一个变化,是之前每一次导航到一个 Composable 函数页面,都将打开一个全新的 Activity 来承载这个函数体,因此开发们会有一个错误认知:Compose Scope = Activity Scope = ViewModel Scope,在副作用处理上容易出现错误和遗漏,例如:

// ViewModel 中,将 toast 信息作为 State 的一部分暴露
data class PageState(
    val items: List<Item> = emptyList(),
    val toast: ToastContent? = null,  // 一次性事件,混在持久状态中
)

class SomeViewModel : ViewModel(){
    val state: StateFlow<PageState> = ...

    fun onAction(action: Action) {
        // 某些操作会产生 toast
        _state.update { it.copy(toast = ToastContent("操作成功")) }
    }
}

@Composable
fun SomePage(viewModel: SomeViewModel) {
    val state by viewModel.state.collectAsState()
    val toaster = LocalToaster.current

    var otherState = remember { mutableStateOf("") }

    // 通过 snapshotFlow 监听 state 变化来显示 toast
    LaunchedEffect(Unit) {
        snapshotFlow { state.toast }
            .filterNotNull()
            .distinctUntilChanged()
            .collect { toaster.showToast(it.content) }
    }

    // 或者直接判断内容进行显示,都会引发同样的问题。
    // LaunchedEffect(state.toast) {
    //    state.toast?.let { toaster.showToast(it.content) }
    // }

    // ... 页面内容
}

在独立的 Activity 中,这段代码运行起来不会有问题;但是在 Navigation3 的单Activity导航栈模式下,从这个页面跳转到其他页面之后,这个页面将暂时“退出组合”,在返回这个页面之后,重新“进入组合”。

在这个过程中,并不算一次“重组”,而是一次全新的组合事件,上面的代码将会出现:

  1.  不管 LaunchedEffect 的key是什么,都会进入一次执行,snapshotFlow中记录的前值也将被清空,导致 toast 被重复显示;

  2.  通过 remember 保存的状态也被清空,依赖 remember 做的逻辑将回到空态。

针对以上问题,修复思路其实很简单。

对于第一个问题,首先需要开发者确认什么内容该属于“状态”,什么内容该属于“事件”。

在示例代码中,val items: List 属于需要在页面上一直显示的内容,属于业务状态的一部分,使用 StateFlow 和 collectAsState 是很恰当的;而对于 toast 来说,已经显示过一次的toast内容在任何情况下都不该重新出现,因此它该属于“事件流”的一部分,每次消费后都不再重放,因此可以使用 sharedFlow 承载toast的传递,或者每次显示完主动将这个字段清空。

而第二个问题则更简单了,首先区分被 remember 的数据是否能接受丢失,如果是可以丢失的状态(例如,播放中的动画进度)则完全可以不处理;对于真正需要保存的数据,可以通过实现自定义 Saver 使用 rememberSavable 的方式,或者将数据委托给 ViewModel 中保存。

data class PageState(
    val items: List<Item> = emptyList(),
    val toast: ToastContent? = null,
)

class SomeViewModel : ViewModel(){
    private val _state = MutableStateFlow(PageState())
    val state: StateFlow<PageState> = _state.asStateFlow()

    // 将 state 中的 toast 字段转换为事件流(replay=0,重新订阅不重放历史事件)
    val toastEvent: SharedFlow<ToastContent> = state
        .map { it.toast }
        .filterNotNull()
        .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 0)

    fun onAction(action: Action) {
        _state.update { it.copy(toast = ToastContent("操作成功")) }
        // 也可以选择显示后立即清空,确保 toast 状态不会被持久持有
        // _state.update { it.copy(toast = null) }
    }
}

@Composable
fun SomePage(viewModel: SomeViewModel) {
    val state by viewModel.state.collectAsState()
    val toaster = LocalToaster.current

    // 需要跨页面跳转保留的状态,改用 rememberSaveable
    var otherState by rememberSaveable { mutableStateOf("") }

    // 消费事件流:重新进入组合时重新订阅,replay=0 保证不会重放已消费的事件
    LaunchedEffect(Unit) {
        viewModel.toastEvent.collect { toast ->
            toaster.showToast(toast.content)
        }
    }

    // ... 页面内容
}

Navigation3使用额外依赖中的 rememberViewModelStoreNavEntryDecorator() 来提供“页面在pop时清空相应viewmodel”的能力。并且在未来这个依赖和ViewModelStore的能力和api将会发生变化,带来更加强大的定制能力。

屏幕状态感知与返回按钮

宽屏模式下,右栏的页面不需要显示返回按钮(因为左栏始终可见)。可以通过自定义 LocalBackIconVisibility 这个 CompositionLocal 控制:

val LocalBackIconVisibility = compositionLocalOf { true }

// 右栏渲染
if (detailEntry != null) {
    CompositionLocalProvider(LocalBackIconVisibility provides false) {
        detailEntry.Content()
    }
}

子页面中通过读取这个值来决定是否显示返回图标:

val showBackButton = LocalBackIconVisibility.current

窄屏模式下 LocalBackIconVisibility 保持默认值 true,页面正常显示返回按钮。

状态栏的控制

在单 Activity 页面导航框架中, SystemUI 配置(如状态栏颜色)如果允许每个页面、每个组件自由控制,将很容易出现UI闪烁等情况。我们通过 SystemUiConfiguration 收集机制解决:每个 entry 通过 collectSystemUiConfiguration Modifier 上报自己的配置,NavDisplay所在的宿主页面 取栈顶 entry 的配置应用到宿主:

// ① 定义:持有状态栏配置的可观察容器
@Stable
class StableSystemUiConfiguration {
    var statusBarDarkIcons: Boolean? by mutableStateOf(null)
}

// ② 宿主:为每个 NavKey 分配一个 config 对象,并将"锚点 modifier"传给 entry
val configurationMap = remember { mutableStateMapOf<MyNavKey, StableSystemUiConfiguration>() }
val topConfiguration by remember {
    derivedStateOf { backStack.lastOrNull()?.let { configurationMap[it] } }
}

val getCollectorModifier: (MyNavKey) -> Modifier = { key ->
    val config = configurationMap.getOrPut(key) { StableSystemUiConfiguration() }
    // collectSystemUiConfiguration 在 modifier 链中埋入"锚点",持有 config 的引用
    Modifier.collectSystemUiConfiguration(config)
}

NavDisplay(
    // 将栈顶 entry 的配置应用到 Window(状态栏颜色等)
    modifier = Modifier.applySystemUiConfiguration(topConfiguration),
    entryProvider = entryProvider {
        // 在构建 entryProvider 时将 collector modifier 传入页面
        entry<SomeNavKey> { key ->
            SomePage(modifier = getCollectorModifier(key))
        }
    },
    ...
)

// ③ 子页面:将自己期望的状态栏配置追加到 modifier 链上
@Composable
fun SomePage(modifier: Modifier = Modifier) {
    val isDarkTheme = LocalDarkTheme.current
    Box(
        // statusBarDarkIcons 会沿 modifier 链向上查找"锚点",找到后将值写入宿主的 config 对象
        // 节点 attach 时写入,detach 时自动清空,生命周期安全
        modifier = modifier.statusBarDarkIcons(darkIcons = !isDarkTheme)
    ) {
        // 页面内容
    }
}

图片

而 NavDisplay 本身所在的页面中,框架已经传入了一个collectSystemUiConfiguration,并且将实际在 window 中生效。通过显式传递控制链条的方式,我们将状态栏的配置权限限制在页面宿主层级,在这一层让业务根据自己的实际逻辑决定内部组件的生效范围。

返回事件的处理

与 Navigation3 库同时推出的,是 androidx.navigationevent 库,用来响应和发送页面导航事件。Nav3 库默认已经使用了这个依赖库来响应返回事件,其行为是将现有的 backStack 的最新一个元素推出。如果我们需要定制返回事件的处理,可以通过包装 backStack 实现。

需要注意的是,androidx.navigationevent 库会将系统返回手势、系统导航栏返回键、应用顶部导航栏返回按钮或其他主动调用 backHandler.backCompleted() 处的返回事件一同给出,现有的注册层级结构关系不能区分出返回事件的来源行为和来源页面。因此,暂时无法实现“分栏页面各有一个返回按钮,各自控制其栏位的页面pop”交互。

原生页面嵌入

在需要进行宽屏适配的模块中,部分页面仍然是 Android Fragment 实现,尚未迁移到 CMP。我们选择通过 BiliNativePage 将 Fragment 嵌入 Navigation3 体系:

@Composable
internal fun BiliNativePage(url: String, modifier: Modifier){
    val showBackButton = LocalBackIconVisibility.current
    // 1. 通过 Router 解析 URL,获取 Fragment Class
    val routeInfo = Router.newCall(url).find()
    val clazz = routeInfo?.clazz

    // 2. 使用 AndroidFragment 嵌入 Compose
    if (clazz != null) {
        // 3. 因为 AndroidFragment 尚不支持响应state变化主动更新参数,因此选择一个key主动进行重组,通过切换fragment的方式将新的 showBackButton 传入
        // 也可以选择使用 ViewModel 传递 showBackButton 的更新,避免fragment的重建
        key(showBackButton) {
            AndroidFragment(
                clazz = clazz as Class<out Fragment>,
                modifier = modifier,
                arguments = createRouteExtraForFragment(routeInfo).also {
                    it.putBoolean("show_back_button", showBackButton)
                },
            )
        }
    }
}

// Fragment 侧:从 arguments 读取 show_back_button 控制返回按钮显隐
class SomePageFragment : Fragment(R.layout.fragment_some_page) {
    private val showBackButton get() = arguments?.getBoolean("show_back_button", true) ?: true

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.btnBack.isVisible = showBackButton
        binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() }
    }
}

原生页面的注册可以通过 expect/actual 机制来声明,或者使用依赖注入框架来实现页面注册。

showBackButton 的变化会触发 Fragment 重建(通过 key(showBackButton)),确保 Fragment 能响应宽屏/窄屏切换时返回按钮的显隐变化。

Scene 中的小发现

在尝试在Nav3框架内添加页面切换动画过程中,我调研了官方示例 nav3-recipes 中关于动画切换的部分,结果看到了一段让我始料未及的代码:

override val content: @Composable (() -> Unit) = {
    Row(modifier = Modifier.fillMaxSize()) {
        Column(modifier = Modifier.weight(0.4f)) {
            listEntry.Content()
        }
        ...

        Column(modifier = Modifier.weight(0.6f)) {
            AnimatedContent(
                ...
            ) { entry ->
                entry.Content()
            }
         }
     }
}

其中的 entry.Content() 让我产生了“这能正常触发重组吗?”的疑问🤔。在通常的开发惯例中,Composable 函数一般都是独立于Kotlin class的顶层函数,而不是某个实例的成员函数来被调用,这样 Compose 框架可以通过分析入参是否变化来决定是否重组;如果函数本身是一个类对象的成员函数,那类实例的改变会不会产生类似于key改变的作用、从而触发了这个Composable函数的完全重组呢?

带着这个疑问,我构造了一段测试代码:

@Immutable
data class TestClass(val data: String){
    @Composable fun Content(modifier: Modifier = Modifier){
        Text(data)
    }
}

然后使用 jadx 查看它编译后的产物,看到了关键信息:

public final class TestClass {
    public static final int $stable = 0;
    private final String data;
    /* JADX INFO: Access modifiers changed from: private */
    public static final Unit Content$lambda$0(TestClass testClass, Modifier modifier, int i, int i2, Composer composer, int i3) {
        testClass.Content(modifier, composer, RecomposeScopeImplKt.updateChangedFlags(i | 1), i2);
        return Unit.INSTANCE;
    }
    public TestClass(String data){
        Intrinsics.checkNotNullParameter(data, "data");
        this.data = data;
    }
    public final String getData(){
        returnthis.data;
    }
    public final void Content(Modifier modifier, Composer $composer, finalint $changed, finalint i){
        Composer $composer2;
        final Modifier modifier2;
        Composer $composer3 = $composer.startRestartGroup(507862195);
        ComposerKt.sourceInformation($composer3, "C(Content)N(modifier)160@6025L10:ListDetailScene.kt#qpkuy4");
        int $dirty = $changed;
        if (($changed & 48) == 0) {
            $dirty |= $composer3.changed(this) ? 32 : 16;
        }
        if (!$composer3.shouldExecute(($dirty & 17) != 16, $dirty & 1)) {
            $composer2 = $composer3;
            $composer2.skipToGroupEnd();
            modifier2 = modifier;
        } else {
            Modifier modifier3 = (i & 1) != 0 ? Modifier.INSTANCE : modifier;
            if (ComposerKt.isTraceInProgress()) {
                ComposerKt.traceEventStart(507862195, $dirty, -1, "com.example.nav3recipes.scenes.listdetail.TestClass.Content (ListDetailScene.kt:159)");
            }
            $composer2 = $composer3;
            TextKt.m4145TextNvy7gAk(this.data, null, 0L, null, 0L, null, null, null, 0L, null, null, 0L, 0, false, 0, 0, null, null, $composer2, 0, 0, 262142);
            if (ComposerKt.isTraceInProgress()) {
                ComposerKt.traceEventEnd();
            }
            modifier2 = modifier3;
        }
        ScopeUpdateScope scopeUpdateScopeEndRestartGroup = $composer2.endRestartGroup();
        if (scopeUpdateScopeEndRestartGroup != null) {
            scopeUpdateScopeEndRestartGroup.updateScope(new Function2() { // from class: com.example.nav3recipes.scenes.listdetail.TestClass$$ExternalSyntheticLambda0
                @Override // kotlin.jvm.functions.Function2
                publicfinal Object invoke(Object obj, Object obj2) {
                    return TestClass.Content$lambda$0(this.f$0, modifier2, $changed, i, (Composer) obj, ((Integer) obj2).intValue());
                }
            });
        }
    }
}

会发现,这样一种成员Composable函数的产物跟顶层函数没什么区别,都是使用一个生成的数字key作为restart group的标记,在其中判断参数是否变化时,额外进行了 this 对象的判断,也就是说,可以简单将这个函数定义等价为:

data class TestClass(val data: String)

@Composable
fun Content($this: TestClass, modifier: Modifier = Modifier)

基于这一层理解,就能确认,在nav3-recipes示例工程中,scene发生实例变化的时候,也等价于一个普通的Compose重组,其中可以依靠普通的重组、跳过和remember实现动画播放了。

总结

Navigation3 库的出现,极大地减轻了现有 app 在既存路由框架中接入使用的负担,让我们能快速地将现有的 Compose 页面接入其中,完成宽屏适配;同时也推动开发者在编写 Compose 页面的时候更加深入地思考该如何去适配它的生命周期与状态保存,提示代码的交付质量。

-End-

作者丨肖志康

康泰生物:子公司吸附破伤风疫苗获得药品注册证书

36氪获悉,康泰生物公告,公司全资子公司北京民海生物科技有限公司研发的吸附破伤风疫苗于今日收到国家药品监督管理局签发的《药品注册证书》。该疫苗主要用于发生创伤机会较多的人群及妊娠期妇女,以预防破伤风。此次获批将进一步丰富公司产品布局,增强核心竞争力。但产品上市时间存在不确定性。

对话 FREELANDER 神行者 CEO 文飞:我不希望大家买我们的车去拔河、涮泥锅

3 月 31 日 FREELANDER 神行者品牌全球首秀发布会的那场大雨,后来成了很多媒体报道里的开场白。但文飞本人似乎对这种戏剧性的感性叙述不太感兴趣。采访中他很喜欢反复强调的一个词,是「逻辑」。

文飞是 FREELANDER 神行者的全球 CEO,同时兼任奇瑞捷豹路虎的董事和常务副总裁。在此之前,他在传统合资车企待过、在新势力品牌做过、也操盘过老品牌的新产品线,这意味着他在汽车行业是一个「全栈型高管」,也意味着职场履历的教训可能更多,而教训,是一种更深刻的经验。

▲ 经典路虎 FREELANDER 车型

FREELANDER 神行者曾经是路虎旗下的传奇车型,1997-2002 年连续五年斩获欧洲 SUV 畅销榜冠军,如今 FREELANDER 神行者从一款经典车型,正式成为一个独立的全球豪华新能源科技品牌:捷豹路虎方面负责输出美学设计、豪华调性,中方主导整合包括华为乾崑,宁德时代和高通在内的全球供应链和智能化资源。

文飞对这个品牌的窗口期判断是:两年。「两年之内能杀出来就杀出来,杀不出来可能就更难了。」但他立刻补了一句——「这是只讲中国市场的语境。如果放到全球化,就是个变数。」

这个「变数」,正是 FREELANDER 神行者整个故事的核心。

▲ FREELANDER 神行者 Concept 97 概念车

经典 FREELANDER 神行者的底蕴留下,新 FREELANDER 神行者会更大

新的 FREELANDER 神行者品牌首款概念车叫 Concept 97,97 是神行者诞生的年份。这款车外观上延续了路虎家族的城堡式车身比例、三角窗格和垂直切削的尾部,内饰则走向了另一个方向——第三排环抱式沙发、可升降的 Targa 半敞篷尾窗、以及成本「数倍于高档皮革」的创新织物面料。

操刀外观的是 Phil Simmons(菲尔·西蒙斯),初代神行者和第三代揽胜的设计者,2017 款揽胜星脉和 2019 款卫士也出自他手,两款车均拿过世界年度汽车设计大奖。他在发布会上说:

在趋同化的设计浪潮中,Concept 97 以鲜明个性脱颖而出。当大型车往往显得严肃稳重,小型车通常更俏皮活泼,FREELANDER 神行者选择挑战传统的比例与认知,以充满愉悦感的图形语言和鲜活生动的个性表达,重塑了设计的叙事。

原版设计师回归做新品牌的安排在汽车行业相当少见,传递的信号也很清晰:「纯血统」是他们愿意反复强调的概念。但问题也在这里——当「血统」这个词被用滥之后,它的含金量有多少,最终还是要看量产车的状态。文飞自己也承认,6 月份量产车亮相才是「最重要的节点」,届时才能真正验证概念车的设计转化率。

新款神行者在尺寸上做了明显的升级。文飞说,老款 4 米 6 到 4 米 7 的紧凑型定位不会延续,「全系会偏中大型」,原因一是新能源总布置需要更大空间,二是想「脱离老神行者给大家的刻板印象」。神行者的技术平台支持 2850mm 到 3250mm 的轴距带宽,这意味着平台从中型 SUV 到全尺寸旗舰 SUV 都能覆盖,

文飞强调,FREELANDER 神行者目前规划的 5 年 6 款车全部是 SUV,没有轿车规划,并且这六款车都会具有全智能和全地形能力。

FREELANDER 神行者变大了,形变了,但底蕴和神韵还在。

这个产品矩阵有一个重要前提:每一款车从立项起就按「1+3+N」的全球版本矩阵开发——一个国内核心版本,同步开发国际左舵、右舵、欧盟三个主力海外版本。文飞算了笔账:

一款基础车研发投入如果是 10 个亿,每一个主力海外版本大概要再加 30%,1+3 就是普通全新整车的两倍,再加上其他海外衍生版,我们每款车的投入都是同类产品的 2.5 倍。我们现在前两款车都是按 1+3+N 来做的,这个战略投入的决心已经放在这里了。

欧盟版本还有额外的开发成本——因为欧七法规即将全面实施,发动机排放系统要单独做一套,「不是优化,是重新的标准」。

动力形式上,中国首发产品以增程为主,文飞坦承纯电不会出现在第一款车上——「在我们这个尺寸上做纯电,总布置、电池成本、车重控制,再加上方盒造型的风阻,会带来很多系统性的技术难题。」增程、插混、纯电三种形式未来都有规划,但会根据不同车型和进入的市场来分配。

华为乾崑、宁德时代和高通齐聚,有什么不一样?

FREELANDER 神行者与华为乾崑、宁德时代、高通三家合作,在今天已经算是相对标准的豪华新能源配置。但他们的叙述方式和大多数品牌有所不同——不只是列参数,而是强调「联合研发」的深度。

以华为的合作为例,最值得关注的是双方联合开发的 i-ATS 智能全地形系统。这是全球首个把高线程激光雷达数据引入全地形驾驶逻辑的方案:896 线激光雷达加上双目 800 万像素摄像头,通过 AI 大模型实时分析地形,毫秒级响应并自动匹配驾驶模式。前机械差速锁、后 e-LSD 限滑差速器、虚拟中锁,加上闭式双腔空悬,这套硬件体系文飞把它称为「全地形魔毯」。

▲ i-ATS 智能全地形系统示意

采用华为乾崑智驾方案和 896 线激光雷达的产品已经不少,但车辆本身具有全地形能力的并不多,因而 i-ATS 智能全地形系统具有全球独一份儿的价值。

高通方面,全系首批搭载骁龙 8397 车规级芯片,CPU 算力是骁龙 8295 的 3 倍,NPU 算力达 12 倍。在这个基础上,品牌构建了「神行大陆」的数字交互体系。有媒体问到 8397 的算力能否支持透明底盘结合 AR 实景导航这类更深度的场景集成,文飞没有否认,说智能座舱「信息量特别大」,会有专项发布。

三电层面,与宁德时代联合开发的「全地形专用骁遥增·混电池」全系标配 800V 高压增程平台,峰值充电功率350kW,6C 超快充速率。针对越野场景,电池底部采用 FD 高分子涂层,撕裂强度是常规 PVC 的 10 倍,防盐雾周期 20 年。宁德时代方面还首次引入烟气与高压主动隔离技术,「定向向下热导流,远离乘员舱」。

在技术栈之外,文飞认为他们真正的底层优势在于治理结构本身:

传统合资企业,中外双方相互掰手腕、内耗。所有部门、每个岗位都得来一对。我在传统合资企业多年,深受其害。这边没有这个问题——中外股东各自负责各自擅长的部分,运营由我这个团队来,按战略方向独立执行。只有这样,才能在这种高强度竞争环境下保持快速的决策效率、高效的组织效率和迭代效率。

分工是:中方主导产品定义、智能化技术和供应链整合;英方负责设计美学和豪华调性。文飞兼任奇瑞捷豹路虎合资公司的董事和常务副总裁(目前总裁暂缺),「决策签字批到我就结束」。他把这个安排定义为传统外方总裁与中方常务总裁双头制的打破。

文飞还提到,他们每周六都会买来几十台竞品车做横向座舱测评,「我本人、研发 1 号位,都是电动车多年的真实用户,我们对座舱有非常深入的体感。」这句话听起来像是在解释为什么要自己主导这件事,而不是完全交给供应商。

「我不希望大家买我们的车去拔河、拖沙子、涮泥锅」

采访中有一个细节透露出 FREELANDER 神行者的品牌气质边界。文飞说,他们规划了生态合作伙伴,包括阿那亚、小布折叠车等,也会为用户组织自驾活动,但他补了一句:

我不希望大家买我们的车去拔河、拖沙子、涮泥锅。这既失去了我们打造高度智能化的初心,也背离了豪华定位格调的初心。

这句话道出了他们想划定的用户范围:有全地形能力,但不打算往油腻「越野老炮」的圈层里走。

文飞把目标用户描述为「时代新贵」——敬重经典、注重精致细节、愿为设计和质感买单。他分享了一个「541」的竞争来源判断:

50% 最大的销售来源,我们预判是目前新能源中大型 SUV,比如 M9、理想 L 系列。我们相比他们的优势是纯血路虎基因、更惊艳的设计、更有格调的内饰,还有这些城市中大型 SUV 基本不具备的全地形能力。40% 预判来自 BBA 的 34C 燃油轿车的保有用户换购——这批用户只有非常小一部分被问界华为转化,对我们是巨大的市场空间。最后 10% 才是越野偏好用户。

把越野用户放在最后是有意为之。文飞解释说,一旦主打硬派越野,「大家首先关注的是你带不带大梁,这不是我们产品的重心」。文飞的逻辑是:这个市场容量小,而且用户关注点和智能化、豪华格调都不重叠。「我们这个平台确实不带大梁,路虎全系车型也都不带大梁,我不想陷入大梁之争。」

产品的全地形能力并不是摆设。发布会上,负责产品技术的 FREELANDER 神行者高管补充了具体的硬实力:三把锁(前机械差速锁、后 e-LSD、虚拟中锁)、高涉水深度、交叉轴和雪地泥沙工况的调校,加上 i-ATS 智能系统的地形预判能力。「我们的目标是在用户需要的路况下能实现能力,城市通勤是核心,越野是兼顾——讲的是调性,但能力要在。」

关于定价区间的问题,文飞并没有正面回应,但他把定价逻辑说得相当清楚:捷豹路虎的揽胜、卫士、发现系列不会下探价格;揽胜极光和发现运动已经停产,这个区间出现了真空;神行者要在全球范围填补这个空白,「靠外方的成本结构做不下来,才有了这样一个合作模式」。顺着这个逻辑,大致可以推断出他们的目标区间,目前路虎揽胜极光和发现运动的指导价都在 40 万左右,但渠道价都打了 5 折,来到了 20 万的区间。

采访尾声的时候,文飞被问到:一个全新品牌真正站稳脚跟,年销量至少需要到什么量级?

他说:

全球支撑一个品牌运营,我觉得稳态年销 30 万台是最低线。

30 万台,对于一个刚刚完成品牌发布、首款车还没上市的新品牌来说,这个数字后面需要填的东西还很多。他也承认,今年的首要目标不是销量,而是「SOP 之后前三个月,实现最大程度的快速交付,和接近 100% 的用户正向口碑」。

稳中向好。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

我写了个系统,每天上朝批奏折:把 Agent 做成「文武百官」是什么体验

我写了个系统,每天上朝批奏折:把 Agent 做成「文武百官」是什么体验

当小人被关进天牢的那一刻,就是朕决定掏钱的那一刻。

写在前面

09_副本.png

先说结论:当你用「拟圣旨」的方式给 Agent 下指令,看着丞相带着六部尚书在 2D 像素宫殿里跑来跑去给你办差——这玩意儿比你想象的更有成就感。

我叫它 Syntropy(太和),一个基于古代朝廷隐喻的可视化多智能体操作系统。但不是那种换个皮就完事的「国潮包装」,而是真的把 Agent 的执行过程「具象化」了:

  • Agent 不再是日志里的一串 tool_call_pending,而是坐在工位上的像素小人
  • 任务调度不再是抽象的 Orchestrator,而是丞相(Minister)在廷议上发号施令
  • 风险拦截不再是冷冰冰的 await human_approval(),而是把 Agent 关进天牢,等你御批

你可能会问:为什么要搞这么复杂?直接写代码不行吗?

因为现有的 Multi-Agent 框架(LangChain、AutoGen、CrewAI……)有一个共同的问题:它们是黑盒。你能看见输入和输出,但中间的思考、决策、调度、执行,全在一团混沌的日志里。你调了半天 prompt,还是不知道 Agent 卡在哪一步。

所以我的思路很简单:既然 Agent 的执行过程看不见,那就让它「看得见」。

08_副本.png

这篇文章会拆解这套系统的技术架构,但不会堆砌术语。咱们边「上朝」边聊:怎么把状态机映射成像素动画、怎么实现前后端实时同步、怎么设计「御批」机制,以及——为什么「当皇帝」这个隐喻,反而让 Agent 系统更好用了。


1. 问题:Agent 系统的「三大弊政」

在动手之前,我先总结了当前 Multi-Agent 系统的三个「弊政」:

1.1 黑盒化(Black Box)——「爱卿,你到底在想什么?」

Agent 的 Chain-of-Thought(思维链)是一串嵌套的 JSON,工具调用是一条条 log 记录。你很难从这些信息里快速判断:

  • Agent 现在在做什么?是在思考,还是在等你审批?
  • 它在等谁?丞相在等户部尚书查账,还是兵部尚书在调兵?
  • 它卡在哪一步了?是 LLM 没返回,还是工具调用超时?
  • 它的决策依据是什么?为什么它选择了这个方案而不是另一个?

01_副本.png

现状:你只能盯着终端滚屏的日志,祈祷 Agent 别卡死。

1.2 失控风险(Uncontrollable)——「爱卿,这奏折朕没批你就敢执行?」

Agent 自主调用工具是一件很危险的事。删除文件、转账、调用外部 API —— 这些操作一旦失控,后果不可逆。

现有的「Human-in-the-loop」方案要么是简单的 input() 阻塞(体验极差),要么需要入侵式地修改整条执行链路(开发成本高)。

现状:你要么相信 Agent 不会乱来,要么就得自己写一套审核机制。

1.3 记忆遗忘(Amnesia)——「爱卿,昨天说的事你怎么忘了?」

大多数 Agent 框架的记忆系统基于关键词匹配或纯向量检索。关键词匹配漏掉语义相关的信息,纯向量检索又容易在专有名词上翻车。长对话场景下,Agent 往往会「忘记」几轮之前说过的关键信息。

现状:你只能不断「提示」Agent,把上下文塞给它,直到 Token 爆掉。


2. 方案:把 Agent 变成「可观测的臣子」

Syntropy 的核心思路只有一句话:

所见即所思(What you see is what they think)。

具体拆解为三个「治国方略」:

2.1 可视化运行时(Visualized Runtime)——「爱卿们,都动起来」

Agent 的内部状态机(THINKINGACTINGWAITINGERROR)不只在日志里打印,而是实时映射为 2D 像素小人的行为动画

  • THINKING → 小人在原地踱步,头顶冒出气泡(正在思考)
  • ACTING → 小人移动到对应的「工位」(户部查账、兵部调兵、工部造器械……)
  • WAITING_FOR_HUMAN → 小人被「关进天牢」,等待你的御批
  • ERROR → 小人倒地,头顶冒叉(出错了,得查日志)

这套映射让 Agent 的执行状态变得一眼可见。你不再需要翻几百行日志,只需要看一眼沙盘,就知道哪个 Agent 卡住了、它在干什么、它在等谁。

2.2 内核级状态机(Kernel-Level State Machine)——「朝堂规矩,不可乱」

后端 Agent 的生命周期被标准化为一个有限状态机(FSM):

agent-state-machine.png 这个 FSM 是「内核级」的,意味着:

  • LLM 推理(Reasoning)和工具执行(Execution)完全解耦
  • 每个状态的进入和离开都会触发标准化的事件(可观测)
  • 风险拦截、记忆压缩、日志追踪都可以作为「状态钩子」无痕插入

简单说:Agent 不再是「自由发挥」,而是按「朝堂规矩」办事。

2.3 人机协同「御批」协议(Human-in-the-loop)——「这道奏折,朕要亲自批」

每个工具调用都有 riskLevel(low / medium / high)。当 Agent 试图执行高风险操作时:

  1. 内核挂起当前执行流,状态切换为 WAITING_FOR_HUMAN
  2. 前端收到 approval_request 事件,弹出「御批」弹窗
  3. 用户点击「准奏」或「驳回」,通过 Socket 发回指令
  4. 内核恢复执行流(或回滚)

这套机制让「人审」不再是事后补救,而是执行流程的有机组成部分


3. 核心功能:奏折阁与决策树

讲完架构,聊聊实际用起来是什么感觉。

3.1 奏折阁(Imperial Archives)——「每道圣旨,都有迹可循」

在 Syntropy 里,每一次用户指令都被封装为一份**「奏折」。奏折阁是系统的任务管理中心,完整记录了从拟旨 → 受理 → 分发 → 复命**的全过程。

奏折的核心特性

  • 折叠/展开:每份奏折默认折叠,仅展示摘要(如"查Q1税收");展开后可查看完整的对话链路与决策树
  • 多视角叙事:左侧展示百官的回复与思考过程,右侧展示皇帝(用户)的指令,清晰还原对话脉络
  • 状态追踪:每份奏折都有明确的状态标签(待处理 / 进行中 / 已完成 / 已驳回),方便追溯

3.2 决策树可视化——「丞相的思考,一目了然」

当丞相收到一道复杂指令(如「查一下上季度的营收,并对比去年同期」),它不会直接给出答案,而是会拆解任务、调度六部、汇总结果。整个过程形成一棵决策树

decision-tree.png

在奏折阁中,这棵决策树以可视化流程图的形式呈现。你可以清楚地看到:

  • 丞相调度了哪些 Agent
  • 每个 Agent 执行了什么操作
  • 每一步的输入和输出是什么
  • 如果某一步出错,具体卡在哪里

02_副本.png

这对调试和优化至关重要。你不再需要猜"Agent 为什么没按我的预期做事",而是可以直接看到它的决策路径,找出问题所在。

3.3 记忆库(Memory Vault)——「史官的起居注」

记忆库展示 Agent 主动保存的重要信息(个人偏好、项目决策、关键事实),支持:

  • 搜索与过滤:按关键词搜索,或按类别(personal / preference / project / decision)筛选
  • 在线编辑:直接在前端编辑或删除记忆条目,实时同步到后端
  • 语义分类:LLM 根据上下文自动选择记忆类别,无需人工标注

4. 技术实现:朝堂是如何运转的

4.1 前端:React + Phaser 的「双引擎」架构

前端是 Syntropy 最复杂的部分。我们需要同时处理两类需求:

  • UI 层:奏折面板、记忆库、官员状态 HUD、御批弹窗……这些是典型的 React 组件
  • 渲染层:2D 像素沙盘、角色动画、寻路、碰撞检测……这些是游戏引擎的领域

我们的方案是 React-Phaser Bridge

frontend-architecture.png

  • Zustand 作为单一数据源(Single Source of Truth),存储所有 Agent 的状态
  • React 组件订阅 Zustand Store,渲染 UI 面板
  • Phaser 3update() 循环中读取 Store,同步小人动画

这套架构的好处是:UI 和渲染完全解耦。React 不用关心像素坐标,Phaser 不用关心业务逻辑,两者通过 Zustand 的状态桥接。

关键代码片段:状态同步
// store/agentStore.ts
export const useAgentStore = create<AgentStore>((set) => ({
  agents: {},
  updateAgent: (id, updates) =>
    set((state) => ({
      agents: {
        ...state.agents,
        [id]: { ...state.agents[id], ...updates },
      },
    })),
}));
// game/MainScene.ts
export class MainScene extends Phaser.Scene {
  update() {
    const agents = useAgentStore.getState().agents;
    Object.values(agents).forEach((agent) => {
      const sprite = this.sprites[agent.id];
      if (sprite) {
        sprite.updateState(agent.status, agent.targetPosition);
      }
    });
  }
}

4.2 后端:自研 Agent 框架

Syntropy 的后端完全自研,不依赖任何现有的 Agent 框架(LangChain、AutoGen 等)。原因很简单:现有框架的状态机模型和我们需要的不完全匹配

后端整体架构

backend-architecture.png

后端核心模块:

模块 职责
Kernel Agent 生命周期管理,状态机调度
Agent 单个 Agent 的 LLM 调用、工具执行、状态流转
LLM Provider 统一的 LLM API 抽象(支持 OpenAI / DeepSeek)
MemoryManager 记忆存储与检索(FTS5 + Vector + RRF)
SocketGateway 前后端实时通信
Tracer 全链路追踪与结构化日志
Agent 核心状态机
// server/core/Agent.ts
class Agent {
  private state: AgentState = 'IDLE';

  async processMessage(message: string) {
    this.setState('THINKING');
    const response = await this.llm.chat(message);
    
    if (response.toolCalls) {
      for (const toolCall of response.toolCalls) {
        const risk = this.assessRisk(toolCall);
        if (risk === 'high') {
          this.setState('WAITING_FOR_HUMAN');
          await this.waitForApproval(toolCall);
        }
        await this.executeTool(toolCall);
      }
    }
    
    this.setState('IDLE');
    return response.content;
  }
}

4.3 记忆系统:RRF 混合检索引擎

Syntropy 的记忆系统不是纯向量检索,而是三位一体的混合架构:

  1. SQLite:结构化元数据(时间、类别、Agent ID)
  2. FTS5:全文倒排索引,精准匹配关键词
  3. Vector:语义向量,模糊语义检索

检索时,我们使用 Reciprocal Rank Fusion (RRF) 算法合并 FTS 和 Vector 的结果:

RRF_score = Σ (1 / (k + rank_i))

其中 k 是平滑常数(通常取 60),rank_i 是某条记忆在第 i 个检索引擎中的排名。

为什么需要 RRF?
  • 关键词场景:用户问「昨天的税收是多少」,FTS 能精准命中包含"税收"的记录,Vector 可能因为语义漂移而漏掉
  • 语义场景:用户问「最近有什么异常吗」,FTS 因为没有一个明确的关键词而失效,Vector 能理解"异常"的语义

RRF 让两者互补,召回率显著提升。

记忆压缩(Memory Compression)

当 Agent 进入 SLEEPING 状态时,系统自动调用 LLM 对当日未处理的对话进行摘要,生成 daily_summary 并持久化。这解决了长对话场景下的 Token 溢出问题。

// server/runtime/MemoryManager.ts
async compressMemories(agentId: string, conversations: Conversation[]) {
  const summary = await this.llm.chat(`
    请对以下对话进行摘要,提取关键决策和待办事项:
    ${conversations.map(c => c.content).join('\n')}
  `);
  
  await this.db.insert('memories', {
    agentId,
    type: 'daily_summary',
    content: summary,
    timestamp: Date.now(),
  });
}

4.4 全链路追踪(Tracer)

每个用户指令生成唯一的 traceId,贯穿 Agent 调度、工具调用、LLM 推理全流程。Tracer 记录 8 种诊断事件:

  • agent.turn:Agent 开始处理一轮对话
  • tool.call:工具调用
  • model.usage:LLM 调用及 Token 消耗
  • dispatch:任务分发
  • approval.wait:等待御批
  • approval.done:御批完成
  • agent.stuck:Agent 卡死检测(3 分钟无响应自动告警)
  • memory.save:记忆保存

所有事件自动脱敏(截断 API Key 等敏感信息),便于性能分析和故障排查。


5. 架构反思

5.1 为什么选 Phaser 而不是 Canvas / SVG?

Phaser 是一个专业的 2D 游戏引擎,提供了完整的场景管理、精灵动画、碰撞检测、寻路算法。如果用 Canvas 手写,这些都要从零实现;如果用 SVG,大量 DOM 元素会导致性能问题。

Phaser 的 WebGL 渲染让我们可以轻松实现:

  • 像素小人的平滑移动动画
  • 头顶气泡的动态效果
  • 大规模 Agent 同时活动时的性能保障

5.2 为什么不直接用 LangChain / AutoGen?

LangChain 和 AutoGen 的状态机模型是「扁平」的:Agent 顺序执行 Thought → Action → Observation。但我们需要的是内核级的状态机,能够:

  • 在任意时刻挂起执行流(御批拦截)
  • 在任意时刻注入新状态(外部干预)
  • 标准化所有状态转换事件(可观测性)

现有的框架很难在不破坏封装的前提下实现这些需求。

5.3 视觉隐喻的取舍

「古代朝廷」这套视觉系统是一把双刃剑:

优点

  • 降低了多 Agent 系统的认知门槛,非技术用户也能理解「丞相调度六部」的概念
  • 增强了产品的辨识度和传播性

缺点

  • 增加了设计和开发成本(像素素材、动画、场景布局)
  • 对部分技术用户来说可能显得「花哨」

我们的判断是:可视化的核心价值在于降低认知负担,视觉隐喻只是手段。如果换一套视觉系统(如太空站、工厂流水线),只要核心架构不变,价值依然存在。


6. 开源与未来

Syntropy 目前处于 Beta 阶段,代码已开源:GitHub - zabr1314/Syntropy

未来规划:

  • 技能市场:允许开发者为 Agent 开发自定义技能(类似 VSCode 插件)
  • 多场景模板:除了「朝廷」,提供「太空站」「工厂」等可选视觉主题
  • Agent 编排可视化:拖拽式构建多 Agent 协作链路
  • 分布式部署:支持将不同 Agent 部署在不同节点上

7. 结语:当皇帝的一天

Syntropy 本质上是一次将 AI 黑盒具象化的尝试。我们相信:

当 Agent 的执行过程变得可见、可交互、可干预,多智能体系统才能真正从实验室走向生产环境。

但除此之外,它还有一个不那么「技术」的价值:它让管理 Agent 变成了一件有趣的事

每天早上打开系统,看到丞相已经在廷议上等你,六部尚书各就各位。你拟定一道圣旨,看着小人们在宫殿里跑来跑去办差。有时候他们会卡住,有时候他们会犯错,有时候他们会把奏折递到你面前等你御批——这一刻,你不是在 debug,你是在当皇帝。

如果你对这套架构感兴趣,或者有自己的看法,欢迎在 GitHub 上交流。


相关资源

*ST金比:韩妃投资业绩承诺方收到广东证监局行政监管措施决定书

36氪获悉,*ST金比公告,公司子公司韩妃投资的业绩承诺方广州问美企业管理咨询合伙企业(有限合伙)及黄招标,因未能及时履行业绩补偿承诺,收到广东证监局《行政监管措施决定书》。经查,韩妃投资2021年至2024年净利润均未达到承诺标准,承诺方构成违反承诺行为。广东证监局决定对广州问美和黄招标采取责令改正的监管措施,要求其在30日内报送整改报告并抄报深交所。

乘联分会:2026年1-2月中国汽车出口155万辆,同比增长61%

36氪获悉,乘联分会数据显示,2026年1-2月中国汽车实现出口155万辆,同比2025年同期增速61%,2月中国汽车实现出口75万辆,同比增长79%,环比下降7%,同比和环比走势总体较强。2026年2月中国新能源汽车出口32万辆,同比增长120%,1-2月累计出口67万辆,增长88%的表现很好,新能源车出口表现很强。

鸿蒙隔空传送:一抓一放,内容就到了对面的设备上

引言

跨设备传文件这件事,我们已经做了太多年了——蓝牙配对、扫码互传、聊天窗口转发、甚至给自己发邮件。这些方式都能用,但都不够"顺手"。

鸿蒙 Share Kit 新推出的隔空传送,尝试用一种更自然的交互来解决这个问题:用户对着屏幕做一个"抓取"手势,内容就被"拿"起来了,再对着另一台设备"放下",内容就传过去了。整个过程不需要打开任何传输工具,也不需要手动选择接收设备。

本文面向希望在应用中接入隔空传送能力的鸿蒙开发者,梳理这项功能的工作机制、与系统其他功能的联动关系,以及具体的接入方法。


一、隔空传送是怎么工作的

1.1 基本交互逻辑

隔空传送的核心交互是"一抓一放"——用户在一台设备前做出握拳抓取的手势,设备捕捉到这个动作后,将当前页面的分享内容"抓起来";然后用户面向另一台设备做出释放手势,内容就传送到了对端设备上。

这个手势并不是凭空工作的。它依赖应用侧主动注册分享事件——只有当前页面注册了隔空传送的监听,系统才知道"这个页面有东西可以分享"。如果页面没注册,手势不会触发隔空传送。

1.2 使用前提:打开隔空传送开关

隔空传送默认不是开启状态,用户需要手动打开:

设置 → 系统 → 快捷启动和手势 → 隔空传送

这是设备级的开关,对所有支持隔空传送的应用生效。

1.3 设备信任机制

传输的安全性通过设备信任关系来保障,分两种情况:

  • 同账号设备:如果两台设备登录了相同的华为账号,系统默认它们互相信任,传输时无需额外确认,直接发送。
  • 不同账号设备:需要双端用户各自确认"信任对方设备"。确认后,1 小时内再次传输无需重复确认。超过 1 小时则需要重新建立信任。

这种设计在便捷性和安全性之间取了一个平衡——自己的设备间传东西零障碍,借别人设备传也不会被滥用。


二、隔空传送与隔空截屏的关系

一个容易让人困惑的地方是:隔空传送和隔空截屏共用同一个手势触发。这意味着用户做"抓取"动作时,可能同时触发两件事。系统通过两个开关的组合状态来决定具体行为:

隔空传送开启 隔空传送关闭
隔空截屏开启 图库场景传输原图;其他场景传送截屏 仅截屏,不传送
隔空截屏关闭 图库场景传送原图;其他场景无截屏也不传送 什么都不发生

几个值得注意的细节:

  • 当两个开关都打开,且当前页面注册了隔空传送事件时,抓取手势会同时触发隔空传送和隔空截屏。此时隔空传送的卡片下方会出现"保存截屏至本机"的提示。
  • 首次触发时,默认不保存截屏。用户可以手动勾选保存,系统会记住这个选择,作为下次的默认值。
  • 如果只开了隔空截屏、没开隔空传送,那抓取手势就只是截屏,不会有任何传输行为。

对于开发者来说,不需要关心截屏逻辑——这完全是系统层面的行为。你只需要关注隔空传送的注册和数据准备。


三、接入隔空传送的开发实践

接入隔空传送的核心工作就是三件事:在对的时机注册监听、在回调中准备分享数据、在离开时取消监听

3.1 导入所需模块

import { uniformTypeDescriptor as utd } from '@kit.ArkData';
import { systemShare, harmonyShare } from '@kit.ShareKit';
import { fileUri } from '@kit.CoreFileKit';

这里涉及三个 Kit:ShareKit 提供隔空传送和分享的核心能力,ArkData 中的 uniformTypeDescriptor 用于声明分享内容的数据类型,CoreFileKit 用于将文件路径转换为 URI。

3.2 定义手势触发时的分享逻辑

当用户做出抓取手势时,系统会通过注册的回调把一个 SharableTarget 对象传给你。你需要在这个回调里准备好分享数据,然后调用 sharableTarget.share() 把数据发出去。

private immersiveCallback = (sharableTarget: harmonyShare.SharableTarget) => {
  let uiContext: UIContext = this.getUIContext();
  let contextFaker: Context = uiContext.getHostContext() as Context;
  let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';

  let shareData: systemShare.SharedData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    thumbnailUri: fileUri.getUriFromPath(filePath),
    title: '隔空传送分享卡片标题',
    description: '隔空传送分享卡片描述'
  });

  sharableTarget.share(shareData);
}

这里有一个关键的时间约束:收到回调后,建议在 3 秒内调用 sharableTarget.share(),否则可能因为超时导致传输失败。所以分享数据的准备不宜太重——如果需要读取大文件或做复杂处理,建议提前准备好,在回调中直接使用。

分享数据通过 SharedData 构造,几个关键字段的含义:

  • utd:声明分享内容的统一数据类型。上面的示例使用的是 HYPERLINK,表示分享一个链接。
  • content:实际的分享内容,这里是一个 App Linking 链接。
  • thumbnailUri:传送卡片上显示的缩略图,需要传入文件的 URI。
  • titledescription:传送卡片上的标题和描述文字。

3.3 分享 App Linking 实现直达应用

上面的示例中,content 字段传入的是一个 App Linking 链接。这样做的好处是:对端设备收到后,点击卡片可以直接跳转到对应的应用页面,而不只是打开一个网页。如果你希望实现这种"传送即直达"的体验,应用需要先接入 App Linking。

3.4 在正确的时机注册和取消监听

这一步直接决定了功能能否正常工作。原则很简单:

  • 进入可分享页面时注册,告诉系统"这个页面有内容可以分享"。
  • 离开可分享页面时取消,包括页面销毁和应用退到后台的场景。

注册和取消的方法很直接:

private immersiveListening() {
  harmonyShare.on('gesturesShare', this.immersiveCallback);
}

private immersiveDisablingListening() {
  harmonyShare.off('gesturesShare', this.immersiveCallback);
}

但"什么时候算离开"需要仔细处理。除了页面销毁(aboutToDisappear),应用退到后台也应该取消监听。一个完整的处理方式如下:

aboutToAppear(): void {
  // 页面出现时注册监听
  this.immersiveListening();

  // 同时监听应用退后台事件
  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.on('onBackGround', this.onBackGround);
}

aboutToDisappear(): void {
  // 页面销毁时取消监听
  this.immersiveDisablingListening();

  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.off('onBackGround', this.onBackGround);
}

// 页面隐藏时(包括退后台),通过事件总线通知
onPageHide(): void {
  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.emit('onBackGround');
}

private onBackGround = () => {
  this.immersiveDisablingListening();
}

这里的思路是:通过 onPageHide 捕捉页面隐藏事件(退后台会触发),然后通过 eventHub 发出通知,在通知处理中取消隔空传送监听。这样无论是页面销毁还是应用退后台,监听都会被正确清理。

为什么要这么认真地处理取消监听? 因为如果应用退到后台后监听还在,用户在其他应用中做抓取手势时,可能会意外触发你的应用的分享逻辑,导致不可预期的行为。


四、总结

隔空传送提供了一种非常直觉化的跨设备内容传输方式。从开发者的角度看,它的接入并不复杂,但有几个关键点需要把握好:

  1. 时效性:收到手势回调后 3 秒内必须完成分享调用。分享数据要提前准备,不要在回调中做耗时操作。
  2. 生命周期管理:注册和取消监听必须与页面的生命周期严格对应。特别是应用退后台的场景,容易被忽略。
  3. 与隔空截屏的共存:理解两个开关的组合行为,在产品设计上给用户清晰的预期。
  4. 信任机制:同账号设备间传输是无感的,不同账号需要双向确认。如果你的应用场景经常涉及跨账号传输,可以在引导中提示用户。

如果你的应用有内容分享的需求——无论是链接、图片还是文件——隔空传送都是一种值得接入的自然交互方式。结合 App Linking,还能实现"传送即打开对应页面"的完整体验,这在跨设备协作场景中会非常实用。

浪潮信息直播发布企业级OpenClaw

36氪获悉,4月2日,浪潮信息直播发布企业级OpenClaw方案“企千虾”,为企业规模化部署、管理和应用OpenClaw,打造了安全、高效、易用的全链路方案。
❌