普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月28日首页

保利发展旗下公司等在甘肃成立新公司,含数字技术服务业务

2025年11月28日 12:25
36氪获悉,爱企查App显示,近日,甘肃京保瞬诚信息咨询有限公司成立,法定代表人为任述花,注册资本1000万元人民币,经营范围包括数字技术服务;5G通信技术服务;电线、电缆经营;电力电子元器件销售;人工智能应用软件开发等。股权穿透图显示,该公司由保利发展控股集团股份有限公司旗下南昌保煦置业有限公司、任述花共同持股。

芝加哥商业交易所期货交易因数据中心故障于周五暂停

2025年11月28日 12:23
据芝加哥商业交易所集团驻新加坡的一位发言人称,由于技术问题,该交易所的商品期货及期权交易于周五暂停。该发言人通过电子邮件声明表示:“由于 CyrusOne数据中心出现冷却故障,我们的市场目前处于暂停状态。”“我们的支持团队正在努力尽快解决这一问题,并将尽快向客户通报开盘前的详细信息。”包括原油和棕榈油在内的合约在周五亚洲时段的交易中受到影响。(新浪财经)

SEO听不懂?看完这篇你明天就能优化网站了

2025年11月28日 12:23

SEO.jpg

引言

去年有个做手工的朋友小王跟我吐槽,说他花了三个月搭建了一个自己的作品展示网站,设计特别用心,产品照片也拍得很专业。结果呢?每个月访问量只有可怜的100多人,还有一大半是他自己点进去的。他当时特别沮丧,问我:"是不是我的东西不够好?"

其实不是。我看了他的网站,发现问题根本不在内容质量,而是SEO做得太差了——更直白点说,搜索引擎根本找不到他的网站。后来我教他做了一些最基本的优化,两个月后月访问量涨到了接近10000。他兴奋地给我发消息:"这SEO也太神了吧!"

说实话,SEO听起来挺高深,什么"爬虫"、"索引"、"权重"一堆专业术语。但我今天想告诉你的是:SEO其实没那么神秘,很多技巧今天学了明天就能用。

这篇文章会用最接地气的话解释SEO是什么,然后给你5个配了真实案例的优化技巧。看完你就知道怎么让自己的网站在搜索结果里排得更靠前,还能避开3个新手最容易踩的坑。

SEO到底是什么?(白话解释)

我们先不讲那些术语,我用个简单的比喻。

你去图书馆找书,是不是要么问管理员,要么查电脑目录?搜索引擎就像这个图书馆管理员,它的工作是帮用户找到最合适的"书"(网页)。那问题来了:凭什么把你的网页推荐给用户,而不是别人的?

这就是SEO要解决的问题——让搜索引擎更容易"找到"和"推荐"你的内容。

具体点说,当你在百度或Google搜索"北京好吃的火锅",为什么海底捞、小龙坎这些餐厅排在前面?不只是因为它们有名,更重要的是它们的网站做了优化:标题写得清楚、内容详细、用户评价多、网站打开速度快。搜索引擎会根据这些信号判断:"嗯,这家店应该比较靠谱,推荐给用户吧。"

Google在2022年更新了一个挺重要的标准,叫E-E-A-T。我第一次看到这四个字母也懵了,后来发现其实好理解:

  • Experience(经验):你有没有真实体验过?比如你写"北京火锅推荐",你真的去吃过吗?
  • Expertise(专业):你在这个领域懂不懂行?
  • Authority(权威):别人认不认可你?有没有其他网站链接到你的内容?
  • Trust(可信度):你的信息准确吗?有没有误导用户?

我自己理解下来,就是一句话:搜索引擎现在越来越聪明,它要的是真正对用户有帮助的内容,而不是那些专门为了排名硬凑出来的文章。

2025年最重要的SEO趋势

老实讲,SEO这东西一直在变。我记得2020年的时候,还有人在拼命堆关键词,现在这招早就不管用了,反而会被搜索引擎惩罚。

2024年3月,Google做了一次挺大的核心更新,目标是减少40%的"垃圾内容"。什么叫垃圾内容?就是那种看起来像文章,但读起来完全没营养,纯粹为了凑字数的东西。这个更新之后,很多低质量的内容网站排名直接掉没了。

还有一个趋势你必须知道:网站速度现在成了排名因素。Google推出了一个叫Core Web Vitals的指标,听起来挺技术对吧?其实说白了就是衡量"你的网页卡不卡"。具体来说有三个指标:

  • LCP(最大内容绘制):网页主要内容加载完成要多久?理想状态是2.5秒以内
  • FID(首次交互延迟):用户点击按钮后,网页反应快不快?应该在100毫秒以内
  • CLS(累积布局偏移):你有没有遇到过那种情况——刚想点个按钮,页面突然跳了一下,你点到广告上了?这就是布局偏移,越少越好

我有个做电商的朋友,他的网站之前LCP是4.5秒,用户跳出率(就是打开后马上关掉的比例)高达62%。后来他优化了一下图片压缩,用了CDN(内容分发网络),LCP降到了2.1秒,跳出率直接降到35%,转化率提升了40%。你说这重不重要?

另外,AI对SEO的影响也越来越大。现在搜索引擎能更好地理解用户到底想要什么。比如你搜"怎么让孩子爱上阅读",它不只是匹配关键词,而是真的理解你是个家长,想找实用的育儿方法。所以现在做内容,不能只想着塞关键词,要真的去解决用户的问题。

5个立即可用的SEO技巧(重点来了)

技巧1:关键词不是"塞进去"的,是"融进去"的

我见过太多新手犯这个错误了。我自己刚开始做网站那会儿,也以为SEO就是把关键词往文章里多塞几遍。结果呢?文章标题写成"SEO SEO SEO优化技巧大全",读起来完全不通顺,用户看了就想关掉。

正确的做法是什么?用长尾关键词,自然地融入内容。

什么是长尾关键词?就是那种更具体、更长的搜索词。比如:

  • 大词:"川菜"(竞争激烈,很难排上去)
  • 长尾词:"上班族15分钟能做的川菜家常菜"(竞争小,而且搜这个的人意图明确)

我有个写美食的朋友,原来她的文章标题都是"川菜推荐"、"川菜做法"这种。后来改成"上班族15分钟能做的川菜家常菜"、"新手也能学会的麻婆豆腐",结果流量涨了3倍。为什么?因为搜索这些长尾词的人,就是真的想学做菜的,而不是随便逛逛。

还有个概念叫"关键词集群",听起来挺专业,其实就是一篇文章解决一类相关问题。比如你写"如何选购笔记本电脑",文章里可以自然地涉及"学生笔记本推荐"、"游戏本性能对比"、"轻薄本续航测试"这些相关词。搜索引擎现在挺聪明,能理解这些词是相关的,会给你的文章更高的权重。

错误做法:文章标题"SEO SEO SEO优化技巧" 正确做法:标题"新手网站没流量?这5个SEO技巧帮你解决"

看出区别了吗?第二个标题自然、有吸引力,而且解决了具体问题。关键词"SEO"和"优化"都在里面,但读起来完全不生硬。

技巧2:标题和描述,决定用户点不点你

说真的,这个太重要了。

你的网站排在搜索结果第三位,但如果标题写得不吸引人,用户照样不点你,反而点了排第五的那个。这就是CTR(点击率)的作用。

我给你看个对比:

  • 普通标题:"Python教程"
  • 优化后:"Python入门教程:3天学会写第一个爬虫程序"

哪个更吸引你?肯定是第二个对吧。因为它告诉你:1)适合入门新手;2)只需要3天;3)能学会具体的东西(爬虫)。

有个数据我印象特别深:某个技术博客把标题从"JavaScript基础"改成"JavaScript零基础教程:7天从入门到做出第一个网页",CTR从1.5%直接跳到6.2%,排名还提升了。

那怎么写好标题呢?我的经验是:

  1. 包含核心关键词(让搜索引擎知道你讲什么)
  2. 说明具体价值(用户能得到什么)
  3. 最好有数字(3个方法、5个技巧,人对数字特别敏感)
  4. 不要超过60个字符(太长会被截断)

Meta Description(就是搜索结果下面的那段描述文字)也一样重要。很多人不写,让搜索引擎自己抓取,结果抓的内容乱七八糟。你应该自己写一段150字左右的描述,总结文章核心价值,同时自然地包含关键词。

技巧3:内容质量 > 内容数量(10X内容策略)

我之前也陷入过误区,觉得文章越多越好,一天发三篇。结果呢?每篇都写得很浅,用户看了没收获,排名也上不去。

后来我了解到一个概念叫"10X内容"——就是比竞争对手好10倍的内容。听起来夸张对吧?但这是真的有效。

举个例子。假设你要写"如何选购笔记本电脑",你搜一下这个关键词,看看排名前面的文章都写了什么。

  • A文章:500字,泛泛而谈"要看处理器、内存、硬盘",没有具体型号推荐
  • B文章:3000字,包含15款笔记本实测数据、详细的价格对比表、按使用场景(学生、设计、游戏)分类推荐,还有购买避坑指南

你觉得哪个会排名更高?肯定是B对吧。事实也是这样,我观察了很多竞争激烈的关键词,排在第一页的几乎都是这种深度长文。

什么是好内容?我的理解是:

  1. 深度:不只停留在表面,深入讲清楚原理和细节
  2. 实用:有具体的操作步骤、数据对比、工具推荐
  3. 独特:有你自己的经验和见解,不是复制粘贴别人的内容

我自己写文章的时候,会先搜索这个关键词,看排名前10的文章都讲了什么,然后问自己:"我能提供什么他们没有的价值?"可能是更新的数据、更详细的案例,或者是自己的实践经验。

记住:宁可一个月出一篇高质量文章,也不要一周出七篇水文。

技巧4:外链不是买的,是"赚"来的

外链这个东西,很多人理解错了。

我刚开始的时候,看到一些服务商说"花1000块给你100个外链",我还心动了。幸好我没买,后来才知道那些垃圾外链不仅没用,反而可能害你被搜索引擎惩罚。

**什么是高质量外链?**就是有权威的网站主动链接到你的内容,因为你的内容确实有价值。

比如,某个行业研究机构发布了一份独家的市场分析报告,数据详实、观点独到。结果有50多个行业网站、新闻媒体引用了这份报告,并且附上了来源链接。这50个外链的价值,远远超过你花钱买的1000个垃圾链接。

那怎么"赚"到外链呢?

  1. 做真正有价值的内容:行业报告、深度教程、实用工具,这些东西别人自然会想分享
  2. 客座博客:在相关领域的知名博客上发文章,署名里带上你网站的链接
  3. 行业合作:跟同行互换优质资源,前提是双方内容相关且质量都过关
  4. 社交媒体传播:LinkedIn、Facebook、知乎这些平台也能带来流量,而且有些平台的链接权重还不错

我有个朋友做了一个特别实用的Excel模板工具,免费分享出来,结果被很多办公类公众号、知乎专栏引用,自然带来了几百个外链,网站权重噌噌往上涨。

避坑指南:千万别花钱买那种批量外链服务。搜索引擎现在能识别出来,一旦被发现,你的网站可能直接被降权。

技巧5:技术优化,让网站"跑得快"

这个技巧说起来有点技术,但其实操作起来不难。

我前面提到过,网站速度现在是排名因素。你想啊,用户打开一个网页,等了10秒还在加载,早就烦了关掉了。Google的数据显示,网页加载时间每增加1秒,转化率下降7%。

你可以做这几件事:

  1. 压缩图片:这个最简单也最有效。很多人上传图片都是原图,一张5MB,网页当然慢。用TinyPNG这种工具压缩一下,画质基本没影响,大小能减少70%

  2. 使用CDN:CDN就是内容分发网络,简单说就是把你的网站内容分布到全国各地的服务器上。用户访问的时候,自动从最近的服务器加载,速度当然快

  3. 减少JS和CSS文件:如果你用WordPress或者其他建站工具,很可能加载了一堆用不到的脚本文件。用一些优化插件可以合并、压缩这些文件

  4. 移动端适配:现在超过60%的流量来自手机,如果你的网站在手机上显示错乱、按钮点不到,用户体验差,排名肯定上不去

我之前帮一个做教育的朋友优化过网站。他的网站之前LCP(最大内容绘制时间)是4.5秒,用户跳出率62%。我们做了这些优化:把首页的大图从3MB压缩到500KB,换了个更快的主机,用上了CDN,结果LCP降到2.1秒,跳出率降到35%,最关键的是转化率提升了40%。

你不需要是技术大神,就用这个简单的检查清单:

  • ✓ 所有图片都压缩了吗?
  • ✓ 网站在手机上显示正常吗?
  • ✓ 首页加载时间在3秒以内吗?(用Google PageSpeed Insights测一下)
  • ✓ 有没有死链接或404错误页面?

这些做好了,你的技术SEO基本就及格了。

新手最容易踩的3个坑

说了这么多技巧,我还想提醒你三个常见的错误,我自己当初也踩过。

坑1:过度优化(关键词堆砌)

有个做护肤品的老板,听说SEO重要,就把关键词往文章里猛塞。标题是"护肤品护肤品推荐最好的护肤品",文章每两句话就出现一次"护肤品"。结果呢?网站被Google降权了,排名从第一页直接掉到找不着。

**记住:关键词密度控制在2-3%就够了,最重要的是自然。**如果你读着都觉得别扭,搜索引擎肯定也觉得不对劲。

坑2:忽视用户体验,只为SEO而SEO

我见过一些文章,明明是写给搜索引擎看的,不是给人看的。通篇都是关键词和术语,读起来像机器人写的,完全没有可读性。

这就本末倒置了。**SEO的最终目的是什么?是让真实的用户找到你的内容,并且觉得有用。**如果用户点进来看了10秒就关掉,跳出率高得吓人,搜索引擎会判断"这内容不行",你的排名照样掉。

所以我的建议是:**先写给人看,再考虑SEO。**写完之后再自然地优化一下关键词、标题,而不是为了SEO牺牲可读性。

坑3:期望立竿见影

这个我太理解了。我刚开始做SEO的时候,优化了一周,天天去查排名,发现一点变化都没有,就开始怀疑"是不是方法不对?"

后来我了解到,**SEO是长期投资,通常需要3-6个月才能看到明显效果。**这不是一个快速见效的东西。你今天发了一篇文章,搜索引擎要先爬取、索引、评估,然后慢慢调整排名。

我的建议是:**制定一个至少3个月的SEO计划,持续优化,别急着看短期结果。**很多人就是因为太急,一两周没效果就放弃了,太可惜。

总结一下

说了这么多,我们回顾一下这5个技巧:

  1. 关键词要"融进去":用长尾关键词,自然地融入内容,别硬塞
  2. 标题和描述很关键:决定用户点不点你,要写得有吸引力
  3. 质量大于数量:一篇10X内容胜过十篇水文
  4. 外链要"赚"来的:做有价值的内容,让别人主动链接你
  5. 技术优化要做好:网站速度、移动端适配,这些基础必须有

**SEO是个持续优化的过程,不是一次性工程。**但好消息是,你不需要一次性做完所有事情。从最简单的开始:

  • 今天就检查一下你的网站标题是否优化了
  • 用Google Search Console(免费工具)看看自己的网站数据
  • 优先做好这3件事:关键词、标题、内容质量

如果你现在还没有网站也没关系,把这些概念记住,等你开始做内容的时候,从第一篇文章就把SEO考虑进去,比后期再补救容易多了。

最后想说,SEO没有想象中那么难,也没有捷径。踏踏实实做好内容,用心优化细节,3个月后你回头看,一定会发现变化。

你现在就可以动手试试,挑一个你最想优化的页面,用今天学到的技巧改一改。有问题随时交流,我也一直在学习和实践中。加油!

本文首发自个人博客

恒指午间休盘跌0.24%,恒生科技指数涨0.11%

2025年11月28日 12:03
36氪获悉,恒指午间休盘跌0.24%,恒生科技指数涨0.11%;有色金属、新消费领涨,中国黄金国际涨超5%,泡泡玛特涨超3%,蜜雪集团涨超2%;医药、食品饮料板块跌幅居前,药明康德跌超3%,农夫山泉跌超2%;南向资金净买入20.52亿港元。

香港高等法院扩大对许家印前妻丁玉梅的资产禁制令范围:冻结四地约15亿元资产

2025年11月28日 11:54
据香港高等法院11月26日披露的公告,香港高等法院采纳恒大清盘人的主张,扩大了对中国恒大集团创始人许家印前妻丁玉梅的禁制令范围,将其在加拿大、直布罗陀、泽西岛和新加坡共约2.2亿美元(约合人民币15.58亿元)的资产一并纳入冻结。(澎湃)

前十月深圳实际使用外资297亿元,同比增长8.4%

2025年11月28日 11:51
从2025深圳全球招商大会新闻发布会获悉,2025年1—10月,深圳全市实际使用外资297亿元,同比增长8.4%。其中高技术产业实际使用外资超100亿元,占全市总量三分之一以上,高技术制造业增速尤为显著,达到52.7%;新设外商投资企业数量超过万家,美国杜邦、法国德高、英国BP石油、瑞典海克斯康等知名外资企业纷纷在深新设或追加投资。(21财经)

自研光子计数产业链关键芯片,「宇称电子」希望加速医疗及工业探测进入光子时代 | 早期项目

2025年11月28日 11:48

文 | 张冰冰

编辑 | 阿至

人体内早期的病灶、车辆驾驶中高速移动的障碍物、材料的表面应力状态——这些曾经难以捕捉的信息,借助光子探测技术的发展,有望被更清晰高效地“看见”。

以医疗影像CT探测为例,传统CT探测器采用“间接测量”原理,需先将X线转化为可见光,再转换为电信号,这一过程易导致信息丢失与噪声叠加。而光子计数CT则采用深硅、碲锌镉(CZT)、碲化镉(CdTe)等半导体探测器,可以实现X线至电信号的“直接转换”,对单个X射线光子进行精确计数并测定其能量。

光子计数探测器省掉了闪烁晶体,并引入了像素级的读出电路,因此降低了光学串扰并且可以做更小像素的探测,大幅提升探测精度和灵敏度。此外,光子计数探测器可以单独对X光子进行计数而不再需要做电荷积分,成像速度也大幅提升,成像帧率可以突破数千甚至上万fps。

对医学CT检测来说,这意味着更高的准确率和更低的辐射剂量影响。目前,西门子、佳能、东软医疗、联影医疗等光子计数CT已进入商业化阶段。

高精度、高集成度的ASIC读出芯片是光子计数CT的核心部件,需要在单个像素中放置多种电路用于计数和能量测定,同时保证每个像素之间不会互相干扰,并适配晶体的不同电学特性,全球仅有少数团队能完成。

「宇称电子」成立于2017年,专注于单光子敏感器件及读出电路设计,涵盖SPAD、SiPM、高精度单光子信号处理芯片ASIC及相关系统方案。产品可应用于医疗影像、工业检测、智能传感等领域,已推出多款产品投入实际应用。

一、从医疗影像到工业检测,光子探测提升成像效率

「宇称电子」高能射线探测领域的主要产品包括MPI501 X射线光子计数探测器模组、MPT3128能谱型X射线探测器信号读出芯片等。其中MPI501 X射线光子计数探测器模组由ASIC(信号处理)和传感器芯片(光电转换)组成,是一款用于X 射线探测的模组,单芯片有近万个独立像素,可以实现单光子级探测。ASIC可以配合多种Sensor材料,如配合CZT可用于高能量X射线光子计数探测,配合硅传感器芯片可用于物质检测,应力检测,XRF成像等多种应用。

MPT3128能谱型X射线探测器信号读出芯片,共集成128个独立通道,支持正负极性信号收集。结合CZT晶体,最高可记录3MeV的伽马射线,其511keV能峰的能量分辨率可达1%。是集成度位于行业前列的CZT探测器专用读出芯片。

「宇称电子」MPT3128芯片

芯片模组的能力体现在下游产品的性能提升上。在医疗领域,时间分辨率是PET-CT探测关注的关键指标,时间分辨率越小,PET-CT探测精度就越高,使用的核药辐射剂量就越低。

「宇称电子」CEO沈昕嘉介绍,搭载「宇称电子」芯片的PET-CT设备CTR时间分辨率可以达到180皮秒以内,“每家做PET-CT的医疗影像公司最核心的就是芯片,这颗芯片决定了机器的性能上限。我们现在提供的芯片,无论从集成度、处理性能,还是抗干扰性、功耗等其他指标来看,都是全球前列。其中整机小于180皮秒的时间分辨率是一个绝对优势。无论是采用LYSO等闪烁晶体做间接光子探测,还是使用CZT等晶体做直接光子计数,我们都已经准备好了相应方案。”

在工业检测领域,光子计数探测器具备能谱能力,可以根据能量阈值进行分类计数,从而将传统X射线成像中混合在一起的、不同物质的信号分离开来。

“通俗地讲,原有X射线成像出来是密度影像,根据透过率、密度不同,形成灰度图像。但光子探测可以通过测量材料在 K、L 等吸收边的突变能量,利用吸收边能量与元素原子序数对应的关系,实现元素的快速识别与定量分析。”沈昕嘉以安检场景举例,传统的X光安检扫描可以分辨密度高的是金属、密度低的是有机物,而光子计数安检可以精确分辨出物品的金属构成(如稀有金属元素),从而筛选出可疑物品。

在应力检测、新能源电池检测等工业无损检测中,这一特性可以支持更精确的测定数据和更简化的检测程序。

二、匹配产业发展节奏,推动产品量产导入

基于市场空间与行业发展进度,「宇称电子」针对医疗和工业两大市场,采取不同的产品及市场策略。沈昕嘉介绍,医疗影像领域更关注成像速度和能量积分,以大型影像设备探索物理极限为目标。该领域下游整机技术已相对成熟,但国产探测器芯片进度还比较落后,因此「宇称电子」将更多聚焦在材料及芯片的国产替代,目前已与下游光子计数CT大客户深度合作,实现量产和千万级营收。

人体中“最小的骨头”耳中的镫骨CT影像对比 

在工业检测领域,从应力检测、缺陷无损检测到新能源电池检测、矿产探测等,需求门类众多,重点在于以探测器芯片为抓手,去解决具体的检测问题。由于行业下游技术还在摸索阶段,「宇称电子」不仅提供芯片模组,还会提供算法等更多解决方案,帮助下游快速升级到单光子技术时代。

“在新能源电池检测领域,我们目前在极片涂布、滚压等工艺检测上取得了一些突破性进展,已进入工程化阶段和领域。另外,在金属表面应力检测领域,以前探测器芯片依赖进口,现在我们跟下游客户一起,完成了整个应力仪从芯片、模组到整机的自主可控,目前已经通过了国家技术检验,迈向批量生产与产业化阶段。”沈昕嘉总结道。

中国信息通信研究院发布的《信息光子技术发展与应用研究报告(2024年)》显示,根据Photonics21等数据测算,2023年全球光子市场规模(包含信息及能量,核心芯片器件及材料、模块级、系统级产品等)约9200亿美元,其中光算存市场规模约数十亿美元,光连接市场约数百亿美元,光采集和光呈现市场约数千亿美元。在AI产业的拉动下,2027年市场规模预计可达12000亿美元。

针对未来的增长机遇和国际巨头的竞争,沈昕嘉认为「宇称电子」的核心竞争力在于“更懂物理”。碲锌镉、碲化镉等晶体材料在X光成像下有独特的物理特性,基于材料的物理特性如何配套设计电路是其中的关键。“我们一直的观点是芯片必须与材料、系统‘共生’,让芯片在每一道工序都为整体系统服务,而不是让材料和系统去迁就芯片。凭借这种深度耦合的能力,我们已经成为产业链的关键中枢,并携手产业链共同推动真正的系统级创新。”

未来发展上,「宇称电子」将进一步推动医疗和工业领域客户放量,并加速车在激光雷达领域的产品导入,预计未来营收增速保持在每年50%左右。

36氪未来产业

「36氪未来产业」持续关注城市发展、产业转型和创新创业项目落地。寻求报道可邮箱联系wangfengzhi@36kr.com或扫码联系。

此外,今年36氪正式推出《36氪企业投资指南内参》,依托在经济圈产业群、区域重点推进规划与招商领域的深厚积累,36氪通过提供深入详细、更为及时、独家专有的全面信息服务,为政府部门提供高效、精准的产业项目内参;助力项目方匹配产业资金、链接关键人脉、快速融入新的产业生态。

   

本文来自微信公众号“36氪未来产业”,作者:张冰冰,阿至,36氪经授权发布。

交易员称芝商所大宗商品期货交易暂停

2025年11月28日 11:48
据关注芝加哥商品交易所(Chicago Mercantile Exchange)的交易员称,大宗商品期货的实时交易已经停止。包括原油和棕榈油在内的合约在周五亚洲早盘交易中受到影响。芝商所没有立即回复媒体的询问。(新浪财经)

国家能源局综合司发布关于组织开展“人工智能+”能源试点工作的通知

2025年11月28日 11:46
36氪获悉,国家能源局综合司发布关于组织开展“人工智能+”能源试点工作的通知。通知提到,聚焦《国家发展改革委 国家能源局关于推进“人工智能+”能源高质量发展的实施意见》提出的八大类场景、37个重点任务、百余项具体应用,按照“少而精”的原则,面向能源企业征集发展所需、行业所盼和企业所急的高价值应用场景需求,有效减少能源场景需求方与人工智能技术供给方间的信息壁垒。组织人工智能技术供给方依据场景需求“揭榜挂帅”,承接高价值场景建设相关任务。场景需求方与技术供给方合作形成试点项目方案,经国家能源局遴选认定为“人工智能+”能源试点项目,探索形成综合解决方案可规模复制、商业模式可参考借鉴的“人工智能+”能源融合发展新范式,推动提升能源行业智能化发展水平。

A股三大指数午间休盘集体上涨,福建本地股走强

2025年11月28日 11:33
36氪获悉,A股三大指数午间休盘集体上涨,沪指涨0.21%,深成指涨0.72%,创业板指涨0.71%;福建本地股走强,海通发展、福建水泥涨停,平潭发展涨超4%;锂矿、半导体领涨,大中矿业涨停,盛新锂能涨超6%,长光华芯涨超4%;银行、制药板块跌幅居前,众生药业跌超8%,邮储银行、中国银行跌超1%。

荷兰安世半导体:各行业客户仍反映即将面临停工

2025年11月28日 11:27
当地时间11月27日,荷兰安世半导体发布致安世中国领导层的公开信,称通过常规渠道的多次直接沟通安世中国均未奏效,呼吁安世中国领导尽快回应,以建设性的方式参与对话,着力恢复供应链,以及下一步确保与公司治理保持一致,遵循安世半导体全球管理层的合法指令。(证券时报)

香港建造业总工会:对业界金属棚架取代竹棚架持开放态度,全面转型需超两年

2025年11月28日 11:23
据香港电台网站11月28日报道,香港特区发展局昨日与业界会面,商讨如何推进金属棚架取代竹棚架的路线图,局方表示,与会者同意制定“非一刀切”但方向清晰的行动纲领,包括下一步工作需要识别何种工作情景比较适合转用金属棚架。香港建造业总工会理事长周思杰表示,工会对业界的转型持开放态度,但关注金属棚架的储存和运输等问题,估计要全面转型需时超过两年。(界面)

长光华芯旗下公司等成立光电科技新公司,含半导体相关业务

2025年11月28日 11:21
36氪获悉,爱企查App显示,近日,苏州星沅光电科技有限公司成立,法定代表人为邱二虎,注册资本625万元人民币,经营范围包括电子专用材料研发、光电子器件制造、电力电子元器件销售、电子元器件制造、半导体分立器件制造等。股权穿透图显示,该公司由ExOptronics Inc、苏州长光华芯光电技术股份有限公司全资子公司苏州长光华芯半导体激光创新研究院有限公司等共同持股。

Windows BLE 开发指南(Rust windows-rs)

作者 zengyuhan503
2025年11月28日 11:21

Windows BLE 开发指南(Rust windows-rs)

本演示在 Windows 平台使用 Rust 的 windows-rs 库进行 BLE(低功耗蓝牙)开发:扫描设备、连接与服务发现、选择特征、启用通知(CCCD)、发送与接收数据、断开与清理,同时给出“已配对设备重启”场景的稳态策略


依赖与准备

Cargo.toml 添加依赖:

[dependencies]
windows = "0.56"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
uuid = "1"

常用导入(放在你的模块或文件顶部):

use windows::{
    core::Result as WinResult,
    Devices::Bluetooth::Advertisement::{
        BluetoothLEAdvertisementWatcher, BluetoothLEAdvertisementReceivedEventArgs,
    },
    Devices::Bluetooth::{BluetoothLEDevice, BluetoothConnectionStatus},
    Devices::Bluetooth::GenericAttributeProfile::{
        GattDeviceService, GattCharacteristic, GattCharacteristicProperties,
        GattClientCharacteristicConfigurationDescriptorValue, GattCommunicationStatus,
        GattValueChangedEventArgs, GattProtectionLevel,
    },
    Devices::Enumeration::{
        DeviceInformationCustomPairing, DevicePairingKinds, DevicePairingRequestedEventArgs,
        DevicePairingResultStatus,
    },
    Foundation::TypedEventHandler,
    Storage::Streams::DataReader,
};

辅助函数:UUID 字符串转 Windows GUID:

fn guid_from_str(s: &str) -> windows::core::GUID {
    let u = uuid::Uuid::parse_str(s).expect("uuid parse error");
    let (d1, d2, d3, d4) = u.as_fields();
    windows::core::GUID::from_values(d1, d2, d3, *d4)
}

扫描与筛选设备

扫描 5 秒并返回设备列表(MAC 为 12 位十六进制字符串):

#[derive(Debug, Clone)]
struct BleDevice { mac: String, name: String, rssi: Option<i16> }

async fn scan_devices(timeout_ms: u64) -> Vec<BleDevice> {
    use std::{collections::HashMap, sync::Arc, time::Duration};
    use tokio::sync::Mutex as AsyncMutex; // 广播事件是异步回调,这里用异步互斥保护聚合表

    // address→设备信息 聚合表(Windows 提供 64 位地址,不是文本 MAC)
    let map = Arc::new(AsyncMutex::new(HashMap::<u64, BleDevice>::new()));
    let watcher = BluetoothLEAdvertisementWatcher::new().unwrap(); // 创建广播监听器
    let map_cb = map.clone();
    // 注册 Received 事件:每条广播提取地址/名称/RSSI 并写入聚合表
    let _token = watcher.Received(&TypedEventHandler::new(
        move |_sender: &Option<BluetoothLEAdvertisementWatcher>, args: &Option<BluetoothLEAdvertisementReceivedEventArgs>| -> WinResult<()> {
            if let Some(args) = args.as_ref() {
                let addr = args.BluetoothAddress()?; // 64 位地址(数值)
                let mac = format!( // 转为 12 位十六进制字符串,便于统一筛选
                    "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
                    (addr >> 40) & 0xff, (addr >> 32) & 0xff, (addr >> 24) & 0xff,
                    (addr >> 16) & 0xff, (addr >> 8) & 0xff, addr & 0xff
                );
                let name = args.Advertisement()?.LocalName()?.to_string(); // 本地名(可能空)
                let rssi = args.RawSignalStrengthInDBm()?; // 信号强度(dBm)
                if let Ok(mut m) = map_cb.try_lock() { // 非阻塞写入,避免回调卡住
                    m.entry(addr).and_modify(|d| { if d.name.is_empty() && !name.is_empty() { d.name = name.clone(); } d.rssi = Some(rssi); })
                        .or_insert(BleDevice { mac, name, rssi: Some(rssi) });
                }
            }
            Ok(())
        }
    )).unwrap();
    watcher.Start().unwrap(); // 开始监听广播
    tokio::time::sleep(Duration::from_millis(timeout_ms)).await;
    watcher.Stop().unwrap(); // 停止监听
    let locked = map.lock().await; // 收敛为列表
    let mut list: Vec<_> = locked.values().cloned().filter(|d| !d.name.is_empty()).collect();
    list.sort_by_key(|d| d.mac.clone());
    list
}

async fn filter_device(mac: &str, list: Vec<BleDevice>) -> Option<BleDevice> {
    // 支持 "AA:BB:..." 或无分隔符/大小写不一致的输入
    let mut s = mac.replace(":", "").replace('-', "").to_lowercase();
    if s.len() != 12 || s.chars().any(|c| !c.is_ascii_hexdigit()) {
        if let Ok(addr_hex) = u64::from_str_radix(&s, 16) { s = format!("{:012x}", addr_hex); }
        else if let Ok(addr_dec) = s.parse::<u64>() { s = format!("{:012x}", addr_dec); }
    }
    list.into_iter().find(|d| d.mac == s)
}

为什么这么做:

  • Windows 广播提供的是数值地址,统一转为 12 位十六进制更利于设备筛选与日志定位。
  • 事件回调中尽量使用 try_lock,避免广播高频导致锁争用。

常见坑:

  • 某些设备广播不带本地名,需允许空名称并在后续才筛掉。
  • 扫描过短可能错过目标设备;根据场景调整 timeout_ms

连接与服务发现

async fn connect_and_list_services(device: &BleDevice) -> BluetoothLEDevice {
    let addr = u64::from_str_radix(&device.mac.replace(":", "").to_lowercase(), 16).unwrap(); // 文本 MAC → 数值地址
    let dev = BluetoothLEDevice::FromBluetoothAddressAsync(addr).unwrap().await.unwrap(); // WinRT 异步获取设备对象
    // 等待连接就绪:避免紧接着读服务/写 CCCD 命中未连接状态
    for _ in 0..10 {
        if dev.ConnectionStatus().unwrap() == BluetoothConnectionStatus::Connected { break; }
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
    }
    // 列出服务(可选):用于确认目标服务是否存在与刷新完成
    let services = dev.GetGattServicesAsync().unwrap().await.unwrap();
    if services.Status().unwrap() == GattCommunicationStatus::Success {
        let list = services.Services().unwrap();
        for s in list.into_iter() { println!("service uuid {:?}", s.Uuid().unwrap()); }
    }
    dev
}
  • 某些设备在建立物理连接后,需要数百毫秒才进入 Connected;过早操作常导致不可达或读空服务。

常见坑:

  • 忽略连接就绪轮询会触发后续“Unreachable”或启用通知中止(E_ABORT)。

特征选择(通知/写入)

按 GUID 过滤,否则枚举回退,并根据属性判定:

async fn select_notify(service: &GattDeviceService, guid: windows::core::GUID) -> Option<GattCharacteristic> {
    let res = service.GetCharacteristicsForUuidAsync(guid).unwrap().await.unwrap();
    if res.Status().unwrap() == GattCommunicationStatus::Success {
        let list = res.Characteristics().unwrap();
        for c in list.into_iter() {
            let props = c.CharacteristicProperties().unwrap(); // 判定具备 Notify 或 Indicate
            let ok = (props.0 & GattCharacteristicProperties::Notify.0) != 0 || (props.0 & GattCharacteristicProperties::Indicate.0) != 0;
            if ok { return Some(c); }
        }
    }
    // GUID 过滤失败时,枚举全部特征并匹配 GUID
    let all = service.GetCharacteristicsAsync().unwrap().await.unwrap().Characteristics().unwrap();
    for c in all.into_iter() { if c.Uuid().unwrap() == guid { return Some(c); } }
    None
}

async fn select_write(service: &GattDeviceService, guid: windows::core::GUID) -> Option<GattCharacteristic> {
    let res = service.GetCharacteristicsForUuidAsync(guid).unwrap().await.unwrap();
    if res.Status().unwrap() == GattCommunicationStatus::Success {
        let list = res.Characteristics().unwrap();
        for c in list.into_iter() {
            let p = c.CharacteristicProperties().unwrap(); // 判定具备 Write 或 WriteWithoutResponse
            let ok = (p.0 & GattCharacteristicProperties::Write.0) != 0 || (p.0 & GattCharacteristicProperties::WriteWithoutResponse.0) != 0;
            if ok { return Some(c); }
        }
    }
    let all = service.GetCharacteristicsAsync().unwrap().await.unwrap().Characteristics().unwrap();
    for c in all.into_iter() { if c.Uuid().unwrap() == guid { return Some(c); } }
    None
}
  • 设备端在刷新期间可能返回空列表,枚举回退能避免漏选;属性判定可避免误选不可用特征。

常见坑:

  • 仅按 GUID 命中但属性不满足(无 Notify/Write),后续启用或写入会失败。

启用通知(CCCD)与回调注册

async fn enable_notify_with_retry(ch: &GattCharacteristic) -> bool {
    tokio::time::sleep(std::time::Duration::from_millis(1000)).await; // 预延时,避开设备忙/栈刷新
    for _ in 0..3 {
        if let Ok(op) = ch.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::Notify) {
            if let Ok(status) = op.await { if status == GattCommunicationStatus::Success { return true; } } // 首选 Notify
        }
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }
    for _ in 0..2 {
        if let Ok(op) = ch.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::Indicate) {
            if let Ok(status) = op.await { if status == GattCommunicationStatus::Success { return true; } } // 回退 Indicate
        }
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }
    false
}

fn register_value_changed(ch: &GattCharacteristic, mut on_notify: impl FnMut(Vec<u8>) + Send + 'static) {
    let handler = TypedEventHandler::<GattCharacteristic, GattValueChangedEventArgs>::new(
        move |_sender: &Option<GattCharacteristic>, args: &Option<GattValueChangedEventArgs>| {
            if let Some(args) = args.as_ref() {
                if let Ok(buf) = args.CharacteristicValue() {
                    if let Ok(reader) = DataReader::FromBuffer(&buf) { // IBuffer → Vec<u8>
                        if let Ok(len) = buf.Length() {
                            let mut data = vec![0u8; len as usize];
                            let _ = reader.ReadBytes(&mut data);
                            on_notify(data);
                        }
                    }
                }
            }
            Ok(())
        }
    );
    let _ = ch.ValueChanged(&handler);
}
  • Notify 不需要 ACK,实时性好;设备只支持 Indicate 时需回退。
  • IBuffer 转字节后交给上层解析,避免 WinRT 读取阻塞。

常见坑:

  • 启用通知过早会被中止(E_ABORT);加延时与重试能显著降低概率。

写入(带响应优先,失败回退无响应)

async fn write_with_result_and_fallback(ch: &GattCharacteristic, data: &[u8]) -> GattCommunicationStatus {
    use windows::Storage::Streams::{InMemoryRandomAccessStream, DataWriter};
    let stream = InMemoryRandomAccessStream::new().unwrap(); // 构造内存流
    let out = stream.GetOutputStreamAt(0).unwrap(); // 取输出流
    let writer = DataWriter::CreateDataWriter(&out).unwrap(); // 创建写入器
    writer.WriteBytes(data).unwrap(); // 写入字节
    let buf = writer.DetachBuffer().unwrap(); // 拆出 IBuffer

    let res = ch.WriteValueWithResultAsync(&buf).unwrap().await.unwrap(); // 带响应写入
    let status = res.Status().unwrap(); // 读取通信状态(可结合 ProtocolError 定位 ATT 错误)
    if status != GattCommunicationStatus::Success {
        let fb = ch.WriteValueAsync(&buf).unwrap().await.unwrap(); // 回退:无响应写入
        return fb;
    }
    status
}

为什么这么做:

  • 带响应写可获 ATT 错码(权限/长度/不允许等);设备仅支持无响应写时自动回退。

常见坑:

  • 未加密/未配对时常见 Insufficient Authentication;需先建立加密会话。

断开与清理(顺序至关重要)

async fn disconnect_cleanup(dev: &BluetoothLEDevice, notify_char: &GattCharacteristic, token: windows::Foundation::EventRegistrationToken) {
    let _ = notify_char.RemoveValueChanged(token); // 先移除通知事件,避免回调残留
    if let Ok(op) = notify_char.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::None) { let _ = op.await; } // 关闭订阅(CCCD=None)
    if let Ok(svc) = notify_char.Service() { if let Ok(sess) = svc.Session() { let _ = sess.SetMaintainConnection(false); } } // 取消保持连接
    let _ = dev.Close(); // 关闭设备句柄
}
  • 不正确的清理顺序会导致下次连接启用通知失败或会话不可达。

常见坑:

  • 忘记 RemoveValueChanged 导致 ValueChanged 持有对象,写入 None 时报错。

已配对设备重启的稳态策略(清理 + 重试)

设备已在系统层“配对且连接”,当设备重启进入配对模式时,Windows 保留旧连接/GATT 缓存,常见错误:

  • “notify characteristic not found”
  • “enable notify/indicate failed” 或 E_ABORT(0x80004004)

建议在连接前执行 OS 级清理:

async fn unpair_and_close(address: u64) {
    let dev = BluetoothLEDevice::FromBluetoothAddressAsync(address).unwrap().await.unwrap();
    if let Ok(di) = dev.DeviceInformation() {
        if let Ok(pairing) = di.Pairing() {
            if pairing.IsPaired().unwrap_or(false) {
                let _ = pairing.UnpairAsync().unwrap().await;
            }
        }
    }
    let _ = dev.Close();
    tokio::time::sleep(std::time::Duration::from_millis(800)).await;
}

随后再进行连接、服务发现、特征选择与 CCCD 启用,并在各步骤加入适度延时与重试。


组合示例:连接→订阅→发送→接收→断开

#[tokio::main]
async fn main() {
    let list = scan_devices(5000).await;
    let dev = filter_device("208B37997529", list).expect("device not found");

    let addr = u64::from_str_radix(&dev.mac, 16).unwrap();
    unpair_and_close(addr).await; // 已配对场景建议先清理

    let device = connect_and_list_services(&dev).await;
    let service = {
        let guid = guid_from_str("01000100-0000-1000-8000-009078563412");
        let list = device.GetGattServicesAsync().unwrap().await.unwrap().Services().unwrap();
        list.into_iter().find(|s| s.Uuid().unwrap() == guid).expect("service not found")
    };

    tokio::time::sleep(std::time::Duration::from_millis(800)).await;
    let notify = select_notify(&service, guid_from_str("02000200-0000-1000-8000-009178563412")).expect("notify not found");
    let writec = select_write(&service, guid_from_str("03000300-0000-1000-8000-009278563412")).expect("write not found");

    let _ = notify.SetProtectionLevel(GattProtectionLevel::EncryptionRequired);
    let _ = writec.SetProtectionLevel(GattProtectionLevel::EncryptionRequired);

    if !enable_notify_with_retry(&notify).await { panic!("enable notify failed"); }
    register_value_changed(&notify, |data| { println!("notify: {} bytes", data.len()); });

    let payload = b"example payload";
    let status = write_with_result_and_fallback(&writec, payload).await;
    println!("write status: {:?}", status);

    // 清理
    // 注意:真实项目中保存 ValueChanged 注册的 token,并在断开时传入
    let dummy_token = windows::Foundation::EventRegistrationToken { value: 0 };
    disconnect_cleanup(&device, &notify, dummy_token).await;
}

故障排查与最佳实践

  • WinRT await 与并发:将涉及 WinRT await 的代码放到阻塞线程或在当前线程执行,避免 Send 约束问题
  • 连接就绪:轮询 BluetoothConnectionStatus::Connected 再进行特征与 CCCD 操作
  • 特征与 CCCD:获取服务后延时、特征选择重试;CCCD 启用延时、Notify→Indicate 回退;必要时重新抓取一次特征再启用
  • 已配对设备重启:务必先执行 UnpairAsync + Close + 延时 再连接,显著降低缓存不一致导致的失败
  • 断开顺序:移除事件→CCCD=None→取消保持连接→Close 设备;不当的顺序会让下次连接启用通知失败

小结

本文给出了一套完整的、可直接复制的 Windows BLE 开发代码片段与操作步骤,涵盖扫描、连接与服务发现、特征选择、启用通知、写入与接收、断开清理,以及已配对设备重启场景的稳态策略。将这些片段按需组合,即可搭建稳定的 BLE 通信链路。


带详注的代码片段(逐行说明)

1) 扫描与筛选(详注版)

use windows::{
    core::Result as WinResult,
    Devices::Bluetooth::Advertisement::{
        BluetoothLEAdvertisementWatcher, BluetoothLEAdvertisementReceivedEventArgs,
    },
    Foundation::TypedEventHandler,
};
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::sync::Mutex as AsyncMutex;

#[derive(Debug, Clone)]
struct BleDevice { mac: String, name: String, rssi: Option<i16> }

// 扫描指定时长,聚合设备到列表
async fn scan_devices(timeout_ms: u64) -> Vec<BleDevice> {
    // 使用共享 HashMap 聚合:Windows 广播给出的是 64 位地址(非文本 MAC)
    let map = Arc::new(AsyncMutex::new(HashMap::<u64, BleDevice>::new()));

    // 创建广播监听器
    let watcher = BluetoothLEAdvertisementWatcher::new().unwrap();
    let map_cb = map.clone();

    // 注册接收事件:每条广播中提取地址、设备名与 RSSI
    let _token = watcher.Received(&TypedEventHandler::new(
        move |_sender: &Option<BluetoothLEAdvertisementWatcher>, args: &Option<BluetoothLEAdvertisementReceivedEventArgs>| -> WinResult<()> {
            if let Some(args) = args.as_ref() {
                // 设备地址为 64 位整型,转为 12 位十六进制字符串(不含分隔符)
                let addr = args.BluetoothAddress()?;
                let mac = format!(
                    "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
                    (addr >> 40) & 0xff, (addr >> 32) & 0xff, (addr >> 24) & 0xff,
                    (addr >> 16) & 0xff, (addr >> 8) & 0xff, addr & 0xff
                );
                // 广告包中的本地名
                let name = args.Advertisement()?.LocalName()?.to_string();
                // 信号强度(可能不存在)
                let rssi = args.RawSignalStrengthInDBm()?;

                // 聚合到共享表:若已有记录则更新更有价值的信息(非空名称、最新 RSSI)
                if let Ok(mut m) = map_cb.try_lock() {
                    m.entry(addr)
                        .and_modify(|d| { if d.name.is_empty() && !name.is_empty() { d.name = name.clone(); } d.rssi = Some(rssi); })
                        .or_insert(BleDevice { mac, name, rssi: Some(rssi) });
                }
            }
            Ok(())
        }
    )).unwrap();

    // 开始监听指定时间窗口
    watcher.Start().unwrap();
    tokio::time::sleep(Duration::from_millis(timeout_ms)).await;
    watcher.Stop().unwrap();

    // 收敛为列表并按 MAC 排序,过滤空名称
    let locked = map.lock().await;
    let mut list: Vec<_> = locked.values().cloned().filter(|d| !d.name.is_empty()).collect();
    list.sort_by_key(|d| d.mac.clone());
    list
}

// 按 MAC 文本筛选设备:支持去分隔符与大小写统一,兼容十六/十进制
async fn filter_device(mac: &str, list: Vec<BleDevice>) -> Option<BleDevice> {
    let mut s = mac.replace(":", "").replace('-', "").to_lowercase();
    if s.len() != 12 || s.chars().any(|c| !c.is_ascii_hexdigit()) {
        if let Ok(addr_hex) = u64::from_str_radix(&s, 16) { s = format!("{:012x}", addr_hex); }
        else if let Ok(addr_dec) = s.parse::<u64>() { s = format!("{:012x}", addr_dec); }
    }
    list.into_iter().find(|d| d.mac == s)
}

2) 连接与服务发现(详注版)

use windows::Devices::Bluetooth::{BluetoothLEDevice, BluetoothConnectionStatus};
use windows::Devices::Bluetooth::GenericAttributeProfile::{GattCommunicationStatus};

// 建立到设备的连接,并打印服务列表(用于诊断)
async fn connect_and_list_services(device: &BleDevice) -> BluetoothLEDevice {
    // 文本 MAC → 64 位地址(十六进制解析)
    let addr = u64::from_str_radix(&device.mac.replace(":", "").to_lowercase(), 16).unwrap();
    // 异步获取设备对象(WinRT)
    let dev = BluetoothLEDevice::FromBluetoothAddressAsync(addr).unwrap().await.unwrap();

    // 连接就绪等待:部分设备需要一点时间进入 Connected 状态
    for _ in 0..10 {
        if dev.ConnectionStatus().unwrap() == BluetoothConnectionStatus::Connected { break; }
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
    }

    // 枚举 GATT 服务并打印 UUID(便于确认服务是否刷新与存在)
    let services = dev.GetGattServicesAsync().unwrap().await.unwrap();
    if services.Status().unwrap() == GattCommunicationStatus::Success {
        let list = services.Services().unwrap();
        for s in list.into_iter() { println!("service uuid {:?}", s.Uuid().unwrap()); }
    }
    dev
}

3) 特征选择(详注版)

use windows::Devices::Bluetooth::GenericAttributeProfile::{
    GattDeviceService, GattCharacteristic, GattCharacteristicProperties, GattCommunicationStatus,
};

// 选择通知特征:先 GUID 过滤 + 按属性检查;失败枚举全部回退
async fn select_notify(service: &GattDeviceService, guid: windows::core::GUID) -> Option<GattCharacteristic> {
    let res = service.GetCharacteristicsForUuidAsync(guid).unwrap().await.unwrap();
    if res.Status().unwrap() == GattCommunicationStatus::Success {
        let list = res.Characteristics().unwrap();
        for c in list.into_iter() {
            let props = c.CharacteristicProperties().unwrap();
            let ok = (props.0 & GattCharacteristicProperties::Notify.0) != 0 || (props.0 & GattCharacteristicProperties::Indicate.0) != 0;
            if ok { return Some(c); }
        }
    }
    // 枚举回退:某些设备在刷新期间 GUID 过滤可能返回空
    let all = service.GetCharacteristicsAsync().unwrap().await.unwrap().Characteristics().unwrap();
    for c in all.into_iter() { if c.Uuid().unwrap() == guid { return Some(c); } }
    None
}

// 选择写特征:同理,需具备 Write 或 WriteWithoutResponse 属性
async fn select_write(service: &GattDeviceService, guid: windows::core::GUID) -> Option<GattCharacteristic> {
    let res = service.GetCharacteristicsForUuidAsync(guid).unwrap().await.unwrap();
    if res.Status().unwrap() == GattCommunicationStatus::Success {
        let list = res.Characteristics().unwrap();
        for c in list.into_iter() {
            let p = c.CharacteristicProperties().unwrap();
            let ok = (p.0 & GattCharacteristicProperties::Write.0) != 0 || (p.0 & GattCharacteristicProperties::WriteWithoutResponse.0) != 0;
            if ok { return Some(c); }
        }
    }
    let all = service.GetCharacteristicsAsync().unwrap().await.unwrap().Characteristics().unwrap();
    for c in all.into_iter() { if c.Uuid().unwrap() == guid { return Some(c); } }
    None
}

4) 启用通知与注册回调(详注版)

use windows::Devices::Bluetooth::GenericAttributeProfile::{
    GattCharacteristic, GattClientCharacteristicConfigurationDescriptorValue, GattCommunicationStatus,
};
use windows::Devices::Bluetooth::GenericAttributeProfile::{GattValueChangedEventArgs};
use windows::Storage::Streams::DataReader;
use windows::Foundation::TypedEventHandler;

// 启用通知:优先 Notify,失败回退 Indicate;加入预延时与重试以跨过设备刷新窗口
async fn enable_notify_with_retry(ch: &GattCharacteristic) -> bool {
    // 预延时:避免立即写 CCCD 命中设备忙或栈刷新
    tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
    // 尝试 Notify 多次
    for _ in 0..3 {
        if let Ok(op) = ch.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::Notify) {
            if let Ok(status) = op.await { if status == GattCommunicationStatus::Success { return true; } }
        }
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }
    // 回退 Indicate
    for _ in 0..2 {
        if let Ok(op) = ch.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::Indicate) {
            if let Ok(status) = op.await { if status == GattCommunicationStatus::Success { return true; } }
        }
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }
    false
}

// 注册通知回调:将 IBuffer 转为 Vec<u8> 并交给调用者处理
fn register_value_changed(ch: &GattCharacteristic, mut on_notify: impl FnMut(Vec<u8>) + Send + 'static) {
    let handler = TypedEventHandler::<GattCharacteristic, GattValueChangedEventArgs>::new(
        move |_sender: &Option<GattCharacteristic>, args: &Option<GattValueChangedEventArgs>| {
            if let Some(args) = args.as_ref() {
                if let Ok(buf) = args.CharacteristicValue() {
                    if let Ok(reader) = DataReader::FromBuffer(&buf) {
                        if let Ok(len) = buf.Length() {
                            let mut data = vec![0u8; len as usize];
                            let _ = reader.ReadBytes(&mut data);
                            on_notify(data);
                        }
                    }
                }
            }
            Ok(())
        }
    );
    let _ = ch.ValueChanged(&handler);
}

5) 写入(详注版)

use windows::Devices::Bluetooth::GenericAttributeProfile::{GattCharacteristic, GattCommunicationStatus};
use windows::Storage::Streams::{InMemoryRandomAccessStream, DataWriter};

// 写入封装:优先带响应写入(便于获取协议错误),失败回退无响应
async fn write_with_result_and_fallback(ch: &GattCharacteristic, data: &[u8]) -> GattCommunicationStatus {
    // WinRT 写入 API 需要 IBuffer;这里通过内存流 + DataWriter 构造缓冲区
    let stream = InMemoryRandomAccessStream::new().unwrap();
    let out = stream.GetOutputStreamAt(0).unwrap();
    let writer = DataWriter::CreateDataWriter(&out).unwrap();
    writer.WriteBytes(data).unwrap();
    let buf = writer.DetachBuffer().unwrap();

    // 带响应写入:可读取状态与协议错误码(ATT),便于定位权限或长度问题
    let res = ch.WriteValueWithResultAsync(&buf).unwrap().await.unwrap();
    let status = res.Status().unwrap();
    if status != GattCommunicationStatus::Success {
        // 回退为无响应写:部分设备仅允许无响应写
        let fb = ch.WriteValueAsync(&buf).unwrap().await.unwrap();
        return fb;
    }
    status
}

6) 断开与清理(详注版)

use windows::Devices::Bluetooth::BluetoothLEDevice;
use windows::Devices::Bluetooth::GenericAttributeProfile::{GattCharacteristic, GattClientCharacteristicConfigurationDescriptorValue};

// 断开顺序:RemoveValueChanged → CCCD=None → MaintainConnection(false) → Close
async fn disconnect_cleanup(dev: &BluetoothLEDevice, notify_char: &GattCharacteristic, token: windows::Foundation::EventRegistrationToken) {
    let _ = notify_char.RemoveValueChanged(token);
    if let Ok(op) = notify_char.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue::None) { let _ = op.await; }
    if let Ok(svc) = notify_char.Service() { if let Ok(sess) = svc.Session() { let _ = sess.SetMaintainConnection(false); } }
    let _ = dev.Close();
}

7) 已配对设备重启的清理(详注版)

use windows::Devices::Bluetooth::BluetoothLEDevice;

// 在已配对且系统持有旧连接的情况下:先 Unpair + Close 再连接,降低缓存不一致导致的失败
async fn unpair_and_close(address: u64) {
    let dev = BluetoothLEDevice::FromBluetoothAddressAsync(address).unwrap().await.unwrap();
    if let Ok(di) = dev.DeviceInformation() {
        if let Ok(pairing) = di.Pairing() {
            if pairing.IsPaired().unwrap_or(false) {
                let _ = pairing.UnpairAsync().unwrap().await; // 解除配对,释放旧权限与密钥
            }
        }
    }
    let _ = dev.Close(); // 关闭旧设备句柄
    tokio::time::sleep(std::time::Duration::from_millis(800)).await; // 等待栈刷新
}

8) 组合流程(详注版)

#[tokio::main]
async fn main() {
    // 1) 扫描并选择目标设备
    let list = scan_devices(5000).await;
    let dev = filter_device("208B37997529", list).expect("device not found");

    // 2) 已配对场景建议先清理旧状态(Unpair + Close + 延时)
    let addr = u64::from_str_radix(&dev.mac, 16).unwrap();
    unpair_and_close(addr).await;

    // 3) 连接并输出服务列表(诊断用途)
    let device = connect_and_list_services(&dev).await;

    // 4) 获取目标服务(按 GUID 匹配)
    let service = {
        let guid = guid_from_str("01000100-0000-1000-8000-009078563412");
        let list = device.GetGattServicesAsync().unwrap().await.unwrap().Services().unwrap();
        list.into_iter().find(|s| s.Uuid().unwrap() == guid).expect("service not found")
    };

    // 5) 特征选择前短暂等待,随后选择通知与写特征
    tokio::time::sleep(std::time::Duration::from_millis(800)).await;
    let notify = select_notify(&service, guid_from_str("02000200-0000-1000-8000-009178563412")).expect("notify not found");
    let writec = select_write(&service, guid_from_str("03000300-0000-1000-8000-009278563412")).expect("write not found");

    // 6) 若设备要求加密,设置保护级别为加密
    let _ = notify.SetProtectionLevel(windows::Devices::Bluetooth::GenericAttributeProfile::GattProtectionLevel::EncryptionRequired);
    let _ = writec.SetProtectionLevel(windows::Devices::Bluetooth::GenericAttributeProfile::GattProtectionLevel::EncryptionRequired);

    // 7) 启用通知(带重试/回退),并注册通知回调
    if !enable_notify_with_retry(&notify).await { panic!("enable notify failed"); }
    register_value_changed(&notify, |data| { println!("notify: {} bytes", data.len()); });

    // 8) 写入示例负载(带响应优先,失败回退)
    let payload = b"example payload";
    let status = write_with_result_and_fallback(&writec, payload).await;
    println!("write status: {:?}", status);

    // 9) 断开与清理(真实项目中保存并传入 ValueChanged 的 token)
    let dummy_token = windows::Foundation::EventRegistrationToken { value: 0 };
    disconnect_cleanup(&device, &notify, dummy_token).await;
}

Flutter 图纸标注功能的实现:踩坑与架构设计

作者 明君87997
2025年11月28日 11:20

写在前面

最近在做一个工程验收的项目,有个需求是要在 CAD 图纸上标注问题点。一开始觉得挺简单,不就是显示个图片,点一下加个 Marker 吗?真动手做了才发现,这里面的坑多到怀疑人生。

比如说:

  • 工地现场网络差到爆,必须完全离线
  • 图纸动辄几千像素,加载和交互都卡
  • 业务逻辑一堆,担心后面没法维护
  • 各种坐标系转来转去,脑壳疼

折腾了两周,终于把这个东西搞定了。整个过程中踩了不少坑,也积累了一些经验,所以写篇文章记录一下,顺便分享给有类似需求的朋友。

整体思路

搞这个东西之前,我先理了理需求,发现核心就是:在一张离线图纸上,支持用户点击标注,还得支持区域限制(不能乱点)

听起来简单,但要做好,必须解决几个问题:

  1. **怎么让代码不和业务绑死?**毕竟这个功能不止一个地方用
  2. **怎么管理状态?**标记点、多边形、图纸这些东西状态管理一团乱
  3. **怎么保证性能?**大图加载、高频交互都得优化

想来想去,决定按这个思路来:

CustomMapWidget (视图组件)
     ↓
CustomMapController (控制器,处理逻辑)
     ↓
CustomMapState (状态管理,响应式更新)
     ↓
MapDataSource (抽象接口,业务自己实现)

简单说就是:视图负责展示,控制器负责协调,状态负责响应式更新,业务逻辑通过接口注入

这样的好处是,核心框架和具体业务完全解耦,换个场景只需要实现不同的 DataSource 就行。

关键设计:业务抽象层

这个是整个架构的核心。我定义了一个抽象接口 MapDataSource

abstract class MapDataSource {
  // 加载图纸(可能从本地、可能从服务器)
  Future<MapSourceConfig> loadMapDrawingResource(CrsSimple crs);
  
  // 创建一个标记点(业务自己决定样式)
  Marker addMarker(LatLng point, {String? number});
  
  // 批量加载已有的标记点
  List<Marker> loadMarkers(List<Point<double>>? latLngList, CrsSimple crs);
  
  // 加载多边形(比如房间轮廓、限制区域等)
  dynamic loadPolygons(CrsSimple crs);
}

为什么要这么设计?因为每个业务场景的需求都不一样

  • 验收系统可能需要红色图钉标记问题点
  • 测量系统可能需要数字标记测量点
  • 巡检系统可能需要设备图标

把这些差异抽象出来,让业务层自己实现,核心框架就不用改了。

具体实现

一、状态管理怎么搞

一开始用 Provider 写的,后来发现状态更新太频繁,性能不行。改成 GetX 之后丝滑多了。

class CustomMapState {
  // Flutter Map 的控制器,用来控制缩放、移动等
  MapController mapController = MapController();
  
  // 坐标系统(这个是关键,后面会讲为什么用 CrsSimple)
  final CrsSimple crs = const CrsSimple();
  
  // 配置信息(响应式的,方便动态修改)
  final Rx<MapDrawingConfig> config = MapDrawingConfig().obs;
  
  // 当前使用的图纸
  final Rx<MapSourceConfig?> currentMapSource = Rx<MapSourceConfig?>(null);
  
  // 地图边界(用来做自适应显示)
  LatLngBounds? mapBounds;
  
  // 标记点列表(Rx开头的都是响应式的,改了自动刷新UI)
  final RxList<Marker> markers = <Marker>[].obs;
  
  // 多边形列表(比如房间轮廓)
  final RxList<Polygon> polygons = <Polygon>[].obs;
  
  // 当前正在绘制的点
  final RxList<LatLng> currentDrawingPoints = <LatLng>[].obs;
  
  // 有效区域(用户只能在这个范围内标注)
  List<LatLng> houseLatLngList = [];
}

这里有几个关键点:

  • Rx 系列:GetX 的响应式类型,状态改了UI自动更新,不用手动 setState
  • CrsSimple:简单笛卡尔坐标系,因为图纸用的是像素坐标,不是真的经纬度
  • 多图层分离:标记点、多边形、绘制点分开管理,互不影响

二、控制器的核心逻辑

控制器主要负责协调各个部分,处理用户交互。

初始化流程

_initData() async {
  state.config.value = config;
  try {
    // 调用业务层加载图纸
    var result = await dataSource.loadMapDrawingResource(state.crs);
    state.currentMapSource.value = result;
    state.mapBounds = result.defaultSource.bounds;
  } catch (e) {
    // 这里可能失败,比如文件不存在、网络问题等
    logDebug('加载图纸失败: $e');
  } finally {
    onMapReady(); // 不管成功失败都要走后续流程
  }
}

地图渲染完成的回调

void onMapReady() {
  if (state.isMapReady) return; // 防止重复调用(之前遇到过bug,这里加个保险)
  
  state.isMapReady = true;
  
  // 加载多边形(比如房间轮廓、限制区域等)
  var parameter = dataSource.loadPolygons(state.crs);
  if (parameter['polygonList'] != null) {
    state.polygons.value = parameter['polygonList'];
  }
  
  // 如果有历史标记点,也一起加载进来
  if (config.latLngList.isNotEmpty) {
    state.markers.value = dataSource.loadMarkers(config.latLngList, state.crs);
  }
  
  // 自适应显示整个图纸(不然可能只看到一个角)
  if (state.mapBounds != null) {
    state.mapController.fitCamera(
      CameraFit.bounds(bounds: state.mapBounds)
    );
  }
}

点击事件处理(重点)

这是最核心的逻辑,处理用户在图纸上的点击:

void addDrawingPoint(TapPosition tapPosition, LatLng latlng) {
  // 第一步:坐标转换(从地图坐标转成像素坐标)
  // 为什么要转?因为后端存的是像素坐标,前端显示用的是地图坐标
  Point<double> cp = state.crs.latLngToPoint(
    latlng, 
    state.config.value.serverMapMaxZoom
  );
  
  // 第二步:检查是否超出图纸范围
  // 之前没加这个判断,用户点到图纸外面就报错,体验很差
  if (cp.x < 0 || cp.y < 0 || 
      cp.x > currentMapSource.width ||
      cp.y > currentMapSource.height) {
    showSnackBar('超出图纸范围');
    return;
  }
  
  // 第三步:检查是否在有效区域内
  // 比如验收系统要求只能在房间内标注,不能标到墙外面去
  if (state.houseLatLngList.isNotEmpty &&
      !MapUtils.isPointInPolygon(latlng, state.houseLatLngList)) {
    showSnackBar('请将位置打在画区内');
    return;
  }
  
  // 第四步:通知业务层(让业务层保存数据)
  config.onTap?.call(cp, latlng);
  
  // 第五步:在地图上显示标记点
  addMarker(position: latlng);
}

这个函数看起来简单,但每一步都是踩坑踩出来的:

  • 坐标转换那里,之前 zoom 值没对齐,导致标记点位置偏移
  • 边界检查是测试提的bug,用户点外面会崩
  • 区域约束是产品后来加的需求,还好架构预留了扩展性

三、视图层的设计

视图层就是负责显示,用 Flutter Map 的多图层机制:

@override
Widget build(BuildContext context) {
  return GetBuilder<CustomMapController>(
    tag: tag,  // 用tag支持多实例,不然多个地图会冲突
    id: 'map', // 局部刷新用的,只刷新地图部分
    builder: (controller) {
      return FlutterMap(
        mapController: controller.state.mapController,
        options: _buildMapOptions(),
        children: [
          _buildTileLayer(),      // 底图层(图纸)
          _buildPolygonLayer(),   // 多边形层(房间轮廓)
          _buildMarkerLayer(),    // 标记点层
          ...?children,           // 预留扩展位,可以加自定义图层
        ],
      );
    },
  );
}

Flutter Map 用的是图层叠加的方式,从下往上渲染。顺序很重要,搞错了标记点就被图纸盖住了(别问我怎么知道的)。

底图层的实现

Widget _buildTileLayer() {
  return Obx(() {  // Obx 会监听里面用到的响应式变量
    final currentSource = controller.state.currentMapSource.value;
    
    // 图纸还没加载完,显示loading
    if (currentSource?.defaultSource.localPath?.isEmpty ?? true) {
      return const Center(child: CircularProgressIndicator());
    }
    
    // 加载本地图纸文件
    return OverlayImageLayer(
      overlayImages: [
        OverlayImage(
          imageProvider: FileImage(File(currentSource.defaultSource.localPath)),
          bounds: currentSource.defaultSource.bounds  // 图纸的边界
        )
      ]
    );
  });
}

这里用 OverlayImageLayer 把本地图片当成地图底图,bounds 定义了图片的坐标范围。一开始我还尝试用瓦片图的方式切片加载,后来发现图纸不大(2-3M),直接整图加载反而更简单。

四、工厂模式的应用

为了方便使用,封装了一个工厂类:

class CustomMapFactory {
  static CustomMapWidget createDefault({
    required MapDataSource dataSource,
    required MapDrawingConfig config,
    String? tag,
  }) {
    late CustomMapController controller;
    
    // 检查是否已经创建过(避免重复创建导致内存泄漏)
    if (Get.isRegistered<CustomMapController>(tag: tag)) {
      controller = Get.find<CustomMapController>(tag: tag);
    } else {
      controller = CustomMapController(
        dataSource: dataSource,
        config: config,
      );
      Get.lazyPut(() => controller, tag: tag);  // 懒加载,用的时候才创建
    }
    
    return CustomMapWidget(
      controller: controller,
      tag: tag,
    );
  }
  
  // 页面销毁时记得调用,不然内存泄漏
  static void disposeController(String tag) {
    if (Get.isRegistered<CustomMapController>(tag: tag)) {
      Get.delete<CustomMapController>(tag: tag);
    }
  }
}

使用示例

// 创建地图组件
final mapWidget = CustomMapFactory.createDefault(
  dataSource: MyDataSourceImpl(),  // 你自己的业务实现
  config: MapDrawingConfig(
    serverMapMaxZoom: 8.0,
    onTap: (pixelPoint, latlng) {
      print('点击了坐标: $pixelPoint');
    },
  ),
  tag: 'project_01',  // 用唯一标识,支持多个地图实例
);

踩坑记录

坑一:坐标系统的选择

一开始我用的是常规的地理坐标系(EPSG:3857),结果发现图纸上的坐标根本对不上。后来才明白,CAD 图纸用的是像素坐标,不是经纬度

后端存的坐标是这样的:{x: 1234, y: 5678},单位是像素。而 Flutter Map 默认用的是经纬度坐标。

解决办法是用 CrsSimple(简单笛卡尔坐标系)

// CrsSimple 可以把像素坐标当成"伪经纬度"
final CrsSimple crs = const CrsSimple();

// 地图坐标 → 像素坐标(给后端用)
Point<double> pixelPoint = crs.latLngToPoint(
  latlng, 
  serverMapMaxZoom  // zoom 级别要和后端约定好
);

// 定义图纸的边界
LatLngBounds bounds = LatLngBounds(
  LatLng(0, 0),                      // 图纸左上角
  LatLng(imageHeight, imageWidth)    // 图纸右下角
);

这里有几个坑:

  1. zoom 级别必须和后端一致,不然坐标会偏移。我们约定的是 8
  2. Y 轴方向:CrsSimple 的 Y 轴是向下的,和传统坐标系相反
  3. 小数精度:坐标转换会有浮点误差,存数据库时要注意

坑二:点在多边形内判定

产品要求用户只能在房间内标注,不能标到墙外面去。这就需要判断点是否在多边形内。

我用的是射线法(Ray Casting),原理很简单:从点向右发射一条射线,数射线和多边形边界交点的个数,奇数次就在内部,偶数次就在外部。

static bool isPointInPolygon(LatLng point, List<LatLng> polygon) {
  int intersectCount = 0;
  
  // 遍历多边形的每条边
  for (int i = 0; i < polygon.length; i++) {
    // 取当前点和下一个点(首尾相连)
    final LatLng vertB = 
      i == polygon.length - 1 ? polygon[0] : polygon[i + 1];
    
    // 检查射线是否和这条边相交
    if (_rayCastIntersect(point, polygon[i], vertB)) {
      intersectCount++;
    }
  }
  
  // 奇数次相交说明在内部
  return (intersectCount % 2) == 1;
}

static bool _rayCastIntersect(LatLng point, LatLng vertA, LatLng vertB) {
  final double aY = vertA.latitude;
  final double bY = vertB.latitude;
  final double aX = vertA.longitude;
  final double bX = vertB.longitude;
  final double pY = point.latitude;
  final double pX = point.longitude;
  
  // 优化:快速排除明显不相交的情况
  // 如果AB两个点都在P的上方/下方/左侧,肯定不相交
  if ((aY > pY && bY > pY) || 
      (aY < pY && bY < pY) || 
      (aX < pX && bX < pX)) {
    return false;
  }
  
  // 特殊情况:垂直的边
  if (aX == bX) return true;
  
  // 计算射线与边的交点X坐标(直线方程 y = mx + b)
  final double m = (aY - bY) / (aX - bX);  // 斜率
  final double b = ((aX * -1) * m) + aY;   // 截距
  final double x = (pY - b) / m;           // 交点的X坐标
  
  // 如果交点在P的右侧,说明射线和这条边相交了
  return x > pX;
}

这个算法看起来复杂,其实就是初中的直线方程 y = mx + b。第一次写的时候没考虑垂直边的情况,结果遇到矩形房间就挂了。

坑三:内存泄漏

GetX 虽然好用,但不注意的话很容易内存泄漏。尤其是在列表页,每个 item 都创建一个地图实例,来回滚动几次内存就爆了。

解决方案:

@override
void onClose() {
  if (_isDisposed) return;  // 防止重复释放
  
  super.onClose();
  
  // 释放地图控制器
  state.mapController.dispose();
  
  // 清空所有列表
  state.markers.clear();
  state.polygons.clear();
  state.currentDrawingPoints.clear();
  
  // 重置状态
  state.config.value = MapDrawingConfig();
  state.currentMapSource.value = null;
  state.isMapReady = false;
  
  _isDisposed = true;
}

页面销毁时记得调用:

@override
void dispose() {
  CustomMapFactory.disposeController('project_${projectId}');
  super.dispose();
}

数据模型设计

配置模型

class MapDrawingConfig {
  // 样式相关
  final Color defaultMarkerColor;      // 标记点颜色
  final double defaultMarkerSize;      // 标记点大小
  
  // 缩放相关(这几个参数很重要)
  final double serverMapMaxZoom;  // 后端用的zoom级别(要对齐)
  final double realMapMaxZoom;    // 前端实际最大zoom(影响流畅度)
  final double minZoom;           // 最小zoom(防止缩太小)
  
  // 交互相关
  final bool singleMarker;  // 是否单点模式(有些场景只能选一个点)
  Function(Point<double>, LatLng)? onTap;  // 点击回调
  
  // 数据相关
  List<Point<double>> latLngList; // 已有的标记点(用来回显)
}

配置项不算多,但每个都是实际用到的。一开始想做成超级灵活的配置系统,后来发现太复杂了,就简化成这样。

地图源模型

class MapSource {
  final String localPath;     // 图纸的本地路径
  final LatLngBounds bounds;  // 图纸的边界
  final double height;        // 图纸高度(像素)
  final double width;         // 图纸宽度(像素)
}

class MapSourceConfig {
  final MapSource defaultSource;  // 默认使用的图纸
  
  // 工厂方法:快速创建本地图纸配置
  factory MapSourceConfig.customLocal({
    required String customPath,
    required double height,
    required double width,
  }) { ... }
}

这个模型设计得比较简单,因为我们的需求就是加载一张本地图纸。如果你的场景需要多个图纸切换,可以扩展 availableSources 列表。


性能优化

图层懒加载

没有数据的图层直接返回空 Widget,不渲染:

Widget _buildMarkerLayer() {
  return Obx(() {
    if (controller.state.markers.isEmpty) {
      return const SizedBox.shrink();  // 空图层
    }
    return MarkerLayer(markers: controller.state.markers);
  });
}

局部刷新

用 GetBuilder 的 id 参数实现精准刷新:

update(['map']);  // 只刷新地图,不影响页面其他部分

这个太重要了,之前没加 id,每次更新都全页面刷新,卡得要命。

图片缓存

FileImage 自带缓存,不需要额外处理。但如果图纸特别大(>10M),建议在加载前先压缩一下。


使用指南

第一步:实现数据源接口

根据你的业务需求,实现 MapDataSource

class MyProjectDataSource implements MapDataSource {
  @override
  Future<MapSourceConfig> loadMapDrawingResource(CrsSimple crs) async {
    // 从服务器下载或本地读取图纸
    String localPath = await getDrawingPath();  // 你的业务逻辑
    
    return MapSourceConfig.customLocal(
      customPath: localPath,
      height: 1080,  // 图纸高度
      width: 1920,   // 图纸宽度
    );
  }
  
  @override
  Marker addMarker(LatLng point, {String? number}) {
    // 创建一个标记点(自定义样式)
    return Marker(
      point: point,
      width: 40,
      height: 40,
      child: Icon(Icons.location_pin, color: Colors.red),
    );
  }
  
  @override
  List<Marker> loadMarkers(List<Point<double>>? points, CrsSimple crs) {
    // 加载已有的标记点(比如从数据库读取)
    return points?.map((point) {
      LatLng latlng = crs.pointToLatLng(point, 8.0);
      return addMarker(latlng);
    }).toList() ?? [];
  }
  
  @override
  dynamic loadPolygons(CrsSimple crs) {
    // 加载多边形(房间轮廓、限制区域等)
    return {
      'polygonList': [...],  // 你的多边形数据
      'houseLatLngList': [...],  // 限制区域
    };
  }
}

第二步:创建地图组件

final mapWidget = CustomMapFactory.createDefault(
  dataSource: MyProjectDataSource(),
  config: MapDrawingConfig(
    serverMapMaxZoom: 8.0,
    singleMarker: false,  // 是否单点模式
    onTap: (pixelPoint, latlng) {
      // 用户点击了,这里保存坐标到数据库
      saveToDatabase(pixelPoint);
    },
  ),
  tag: 'project_${projectId}',  // 用唯一ID作为tag
);

第三步:在页面中使用

class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('图纸标注')),
      body: mapWidget,
    );
  }
}

// 页面销毁时记得释放资源
@override
void dispose() {
  CustomMapFactory.disposeController('project_${projectId}');
  super.dispose();
}

几个注意事项

  1. zoom 级别要和后端对齐,不然坐标会偏
  2. tag 必须唯一,建议用项目ID或其他唯一标识
  3. 记得释放资源,不然内存泄漏
  4. 图纸路径要正确,文件不存在会报错

总结

这套架构最大的优点是解耦。核心框架不关心你的业务,只负责地图展示和交互。所有业务逻辑都通过 DataSource 接口注入,换个场景只需要写一个新的 DataSource 实现就行。

当然也有一些不足:

  • 对于特别复杂的标注需求(比如绘制曲线、多边形编辑),还需要扩展
  • 大图纸(>10M)的加载性能还有优化空间
  • 离线缓存目前还没做

不过对于大部分场景来说,已经够用了。

如果你也有类似的需求,希望这篇文章能帮到你。有问题欢迎交流!


2024年实战项目总结,代码已脱敏。

❌
❌