首页 最新 热门 推荐

  • 首页
  • 最新
  • 热门
  • 推荐

一年撸完百万行代码,企业微信的全新鸿蒙NEXT客户端架构演进之路

  • 25-04-17 14:41
  • 3857
  • 6349
juejin.cn

本文由企业微信客户端团队黄玮分享,原题“在流沙上筑城:企微鸿蒙开发演进”,下文进行了排版优化和内容修订。

1、引言

当企业微信团队在2024年启动鸿蒙Next版开发时,我们面对的是双重难题:

  • 1) 在WXG小团队模式下,如何快速将数百万行级企业应用移植到全新操作系统? 
  • 2) 在鸿蒙API 还是Preview的初期,如何保持业务代码的稳定,在API快速更新的浪潮中岿然不动?

DataList框架给出了破局答案(即通过三重机制构建数字负熵流):

  • 1) 结构化熵减:将业务逻辑渲染到UI的过程抽象为数据流,使鸿蒙与Android共享同一套数据驱动的开发机制; 
  • 2) 动态熵减:通过抽象出来的UI数据层屏蔽鸿蒙API的变化,让业务代码历经三个版本的UI层大改而不受影响; 
  • 3) 认知熵减:将跨平台差异封装为一系列通用组件,降低开发者心智负荷,可以专注于业务开发而不用关心技术变更。

本文将要分享的是企业微信的鸿蒙Next客户端架构的演进过程,面对代码移植和API不稳定的挑战,提出了DataList框架解决方案。通过结构化、动态和认知三重熵减机制,将业务逻辑与UI解耦,实现数据驱动开发。采用MVDM分层架构(业务实体层、逻辑层、UI数据层、表示层),屏蔽系统差异,确保业务代码稳定。

2、企业微信客户端框架进化史

罗马不是一天建成的,我们在开发框架方面,也经历了 发现问题、探索方案 、优化改进 的过程。

野蛮生长(2019年前):

  • 1)背景:团队缺乏统一规范,开发风格各异; 
  • 2)问题:相同功能重复实现,维护成本高。

初步探索(2019-2022):

  • 1) 背景:急需统一开发范式,提高开发效率; 
  • 2) 实现:EasyList框架,提出"一切皆列表"理念,封装模板代码,让开发者专注于业务开发; 
  • 3) 问题:未严格隔离业务与UI,退化为MVC模式;抽象能力不足,组件复用率极低。

渐入佳境(2022-2024):

  • 1) 创新:实现了基于数据驱动/分层隔离的DataList框架; 
  • 2) 价值:框架提供抽象能力,降低开发认知负担;让每一个组件都具备复用能力,极大提高了复用率,助力通用组件从个位数突破至50+。

3、企业微信客户端框架整体设计

3.1 整体架构设计

DataList是一套基于数据驱动的分层隔离框架,整体架构图如下图所示。

▲ 图1:DataList MVVM架构图

接下来将从数据流向、分层架构的角度分别对这张图进行讲解。

3.2 数据流向设计

从数据流向的角度,DataList框架可以简单分为Data/List两部分:

  • 1) List:业务逻辑部分,简单来说就是业务数据如何转换为UI数据; 
  • 2) Data:数据驱动部分,UI数据如何渲染为实际的UI/如何驱动UI刷新。

▲ 图2:DataList数据流向图

3.3 MVDM环形分层设计

DataList通过将业务数据到UI数据的转换逻辑独立出来,系统形成了清晰的边界层次:

  • 1) 业务实体层(Repo):负责请求数据,拿到业务数据(保持稳定); 
  • 2) 业务逻辑层(ViewModel):处理业务逻辑,负责业务数据到UI数据的转换(保持稳定); 
  • 3) UI数据层(CellData/ViewData):对UI层的抽象(内部适应变化,对外接口稳定); 
  • 4) 表示层(Cell):处理具体UI渲染(拥抱变化,适配平台新特性)。

相当于MVVM(Model-View-ViewModel)变成了MVDM(Model-View-Data-ViewModel)。

箭头代表依赖指向:

▲ 图3:DataList环形分层图

这里介绍下UI数据层。

将整个控件数据化,即为ViewData:

export class TextData extends BaseData {

text?: string | Resource

fontColor?: ResourceColor

fontSize?: number | string | Resource

fontWeight?: number | FontWeight | string

将多个ViewData组合起来,成为一个组件CellData:

//由Image+Text组成

export class ImgTextCellData extends BaseCellData {

builder: WrappedBuilder<[]> = wrapBuilder(ImgTextCellBuilder)

root: RowData

img?: ImgData //对应Image控件

text?: TextData //对应Text控件

}

由于CellData内不含任何业务代码,所以不受限于业务,天然可以复用。下图是组件复用统计(现有58个组件,数千次复用)。

▲ 图4:通用组件复用统计

这样分层的好处:

  • 1)方便UI大规模复用; 
  • 2)跨平台代码一致性; 
  • 3)隔离业务与UI,UI层变动不影响业务逻辑。

3.4 无可删减:DataList开发示例

完美的达成,不在于无可增添,而在于无可删减。 ——《风沙星辰》 安托万·德·圣-埃克苏佩里

梳理一下,开发一个业务需求,哪些部分是无可删减的?

其实就是业务相关的部分:

  • 1) 数据请求; 
  • 2) 业务数据转为UI(UI数据)。

这些都是必须由开发者填写的逻辑,这些步骤框架最多只能简化,不能代劳。

**比如:**我们开发一个极简版本的人员列表,看下对应步骤。

数据请求:

//Repo对应Model层

class DemoContactRepo():IListRepository {

override fun requestData(

req: DemoContactReq,//请求参数

callback: (rsp: DemoContactRsp) -> Unit,//结果回调

errorCallback: (errorCode: Int, errorMsg: Any?) -> Unit//错误回调

) {

//请求数据,返回

ContactService.getContact(req){contacts->

callback(contacts)

}

}

}

数据转换:

//继承自单数据源列表基类,泛型指明请求与返回的业务数据类型

class DemoContactViewModel: SingleListViewModel() {

/**

* 业务数据转为UI数据

*/

overridefun transferData(data: DemoContactRsp): List {

returndata.contacts.map {

ImgPhotoTextImgCellData( //通用组件

dataId = it.id,

photo = PhotoData(url = it.avatar),//一个图片控件

leftText = TextData(text = it.name))//一个文本控件

}

}

/**

* 拉取数据所用的仓库(对应Model层)

*/

overridefun initRepository(): IListRepository {

return DemoContactRepo()

}

/**

* 初次或刷新页面时的请求参数

*/

overridefun refreshParam(arguments: Bundle?): DemoContactReq {

return DemoContactReq(0,20)

}

}

算上注释,「总计39行」,一个极简版联系人列表就开发完成了。

▲ 图5:DataList联系人 Demo

如果是一个本地静态页面,可以去掉网络请求部分,直接堆砌通用组件(CellData)即可,完整代码只要40行。

//继承自本地静态列表基类,无数据请求

class DemoAttendanceViewModel:LocalSingleListViewModel() {

//...

// 乐高式组件拼装

overridefun transformCellDataList(): List {

return listOf(

attendanceCellData("打卡人员","员工A").section(1),

attendanceCellData("规则名称","打卡规则abc").section(1),

attendanceCellData("规则类型","固定上下班").section(2),

attendanceCellData("打卡时间","周一至周五,09:00-10:00").section(2),

attendanceCellData("打卡方式","手机+智慧考勤机").section(3),

attendanceCellData("打卡位置","天府三街198号").section(3),

attendanceCellData("打卡Wi-Fi", "未设置").section(3),

attendanceCellData("打卡设备", "").section(3),

TextCellData(TextData.tips("位置和Wi-Fi满足任意一项即可打卡")).noneDivider(),

attendanceCellData("加班规则","以加班申请为准").section(4),

attendanceCellData("更多设置","").section(5),

ButtonCellData(ButtonData("删除规则", buttonStyle = R.style.button_l_white, textColor = R.color.day_night_color_chrome_red.getColor())).section(6))

}

//对通用Cell的简单封装

privatefun attendanceCellData(title:String,desc:String):ImgPhotoTextImgCellData{

return ImgPhotoTextImgCellData(/*设置属性*/)

}

}

▲ 图6:DataList静态列表 Demo

3.5 MVDM架构的延迟决策实践

如果想设计一个便于推进各项工作的系统,其策略就是要在设计中尽可能长时间地保留尽可能多的可选项。 ——《整洁架构之道》

通过MVDM分层架构,我们构建了业务逻辑与UI渲染的解耦机制。但真正的考验来自鸿蒙Next开发——当底层API如流沙般变动时,如何保持上层建筑的稳定?

通过UI数据层的隔离,MVDM的UI层历经三个大版本的架构演进,业务层仍保持稳定:

  • 1) 妥协版:快速启动业务开发; 
  • 2) 适配版:拥抱动态属性能力; 
  • 3) 优化版:突破性能瓶颈。

**这三次蜕变完美诠释了"流沙筑城"的技术哲学:**在持续变化的基础设施上,通过架构设计构建确定性。接下来我们将深入每个阶段的演变历程。

4、第一版:系统限制下的妥协

4.1 目标:快速启动

由于我们所有页面都基于DataList开发,需要尽快实现数据绑定能力,让业务开发可以启动。

4.2 实现思路

鸿蒙和Compose一样,UI组件是函数而不是类,没办法像Android那样,拿到控件的对象进行赋值。

@Component

export struct DemoPage{

build(){

Text("Hello World!") //这是一个函数,没法拿到它的对象,也就没法进行动态赋值

}

}

如果要实现数据与UI的绑定,只能在这里对所有属性进行遍历调用.。

4.3 技术方案

在现有API的基础上,我们只能实现这个方案。

▲ 图7:数据绑定第一版

直接把所有属性列出来,全部调一遍,如果data里对应属性没有赋值,就相当于用null调用了一次。

4.4 实践问题

这个方案有很多问题:

  • 1) 即使我在Data里只设置了一个属性,也需要执行一遍所有函数; 
  • 2) 某些属性函数,用null调用和不调用,表现是不一样的,这种属性无法列出; 
  • 3) 太丑,不优雅。

我们迫切需要一个能动态设置属性的方案,因此我向华为官方提出了需求。

▲ 图8:向华为提需求

这个需求交付之后,就有了第二版。

5、第二版:动态属性下的数据绑定

5.1 接入动态属性设置能力

之前提的需求,华为给的解决方案是AttributeModifer。

这是官网的介绍:

▲ 图9:Modifier能力介绍

5.2 技术方案

接入AttributeModifer后,UI层的写法如下:

@Component

export struct WwText {

@ObjectLink data: TextData

@State modifier: TextModifier = new TextModifier(new TextData())

aboutToAppear(): void {

this.modifier.data = this.data

}

build() {

Text(this.data.text)

.attributeModifier(this.modifier) //通过modifier更新属性,不必再调其他函数

}

}

这里更新的原理大致如下图:

▲ 图10:第二版更新机制

TextData被@Observed注解之后,实际上是被动态代理了:

  • 1) 代理类观察到属性变化; 
  • 2) 从记录的set里找到观察者; 
  • 3) 调用观察者的更新函数(实际流程比较复杂,很多调用); 
  • 4) 这个更新函数里面就会执行Modifier里面的applyNormalAttribute函数,最后将属性动态设置到控件上。

WwText编译后的ts代码如下:

//WWText.ts

export class WwText extends ViewPU {

//...

initialRender() {

this.observeComponentCreation2((elmtId, isInitialRender) => {

//这里就是会刷新的部分

Text.create(this.data.text);

Text.attributeModifier.bind(this)(ObservedObject.GetRawObject(this.modifier));

}, Text);

Text.pop();

}

}

5.3 实践问题

实际使用中发现,这套方案有两方面很显著的问题。

1)问题1:代码膨胀:

在实际应用这些Ww系列封装组件的场景,可以看到编译后的代码膨胀的非常明显,两行编译后变成了二十行。

▲ 图11:ets源码/ts产物

一个通用组件,编译后从4k变成了75k。

▲ 图12:编译后体积变化

问题2:性能消耗:

这个写法的性能也非常差,主要是三个方面。

1)冗余刷新:

在applyAttribute这里,如果TextData里面设置了10个属性,但是本次只更新了一个属性,那么在触发更新之后,仍然会10个属性都重新设置一遍。

export class TextModifier extends BaseModifier {

//...

applyAttribute(instance: TextAttribute, data: TextData) {

super.applyAttribute(instance, data)

if (data.fontColor || data.fontColor == 0) {

instance.fontColor(data.fontColor)

}

if (data.textAlign) {

instance.textAlign(data.textAlign)

}

//...

}

}

2)状态管理:

现在鸿蒙这套状态管理机制,在DataList数据绑定的场景下性能不足。查了一下鸿蒙状态管理机制的源码,状态变量是通过动态代理来感知属性变化的,具体一点就是通过SubscribableHandler来代理属性的set、get等操作,源码如下。

class SubscribableHandler{

get(target,property,receiver){

//...

switch(property){

default:

const result = Reflect.get(target,property,receiver)//反射获取属性

if(/*...*/){

let isTracked = this.isPropertyTracked(target, propertyStr);

this.readCbFunc_.call(this.obSelf_, receiver, propertyStr, isTracked);

}

}

}

}

**经过测试:**这个get函数的耗时为万次9ms。而我们的Modifier里面恰好有很多if,需要拿值来判断。

简单算一下,一个页面10个cell,每个cell5个Text,每个Text23个属性+45个基础属性:

一次刷新get次数 = 10X5X(23+45) = 3400次

3400/10000X9 = 3ms

也就是说,没有执行任何具体逻辑,只是取值判断,就消耗了「3ms」。而鸿蒙120帧率的情况,一帧的渲染时间也只有8.3ms。

3)节点增多:

对原生控件进行包装后(Text ==> WwText),View树里会增加一个节点(橙色)。如果某些情况图方便给外层组件又设置了属性,还会再额外增加一个渲染节点(红色)。

比如下面这个组件:

Column(){

WwText({data:this.data1}).width("100%")

WwText({data:this.data2})

}

对应的View树如下:

▲ 图13:节点增多示意

节点从两个变成了五个,而鸿蒙的渲染性能优化就是要求节点越少越好。

6、第三版:基于自定义状态管理的性能优化

6.1 目标:性能优化

第三版的目标就是解决第二版的诸多问题,进行性能优化。

6.2 实现思路

针对这些问题,分析的思路如下:

▲ 图14:第三版问题分析

6.3 技术方案

1)去掉控件包装:

前面提到使用包装控件有两个弊端:

  • 1) 编译后的代码增加,体积增大; 
  • 2) 增加节点,消耗性能。

因此,我们决定去掉包装,使用原生控件。

那么有两个问题:

  • 1) 原本的控件基础逻辑放哪里(比如WwPhoto里加载图片的逻辑); 
  • 2) 之前提到,我们用AttributeModifier时,控件的属性函数我们可以动态调用,但是构造函数不行,那如何更新构造函数?

这两个问题都可以用 AttributeUpdater来解决,它是AttributeModifier的子类。

划重点:

▲ 图15: AttributeUpdater说明-划重点

去掉包装类之后,原本放到包装类里面的基础逻辑,可以放到对应的Updater里面。

例如:

  • 1) WwText ==> Text + TextUpdater; 
  • 2) WwPhoto ==> Image + PhotoUpdater。

2)自定义状态管理:

升级为Updater之后,如果对应的Data仍然是状态变量,那么我们去get的时候消耗依旧。 这里先解释一下,为什么我们的Data要加@Observed注解。

按官方的用法,只有多层嵌套监听的场景才需要@Observed注解

其实这里是因为我们的所有业务逻辑都在ViewModel里面,而不是按照官方方案放在Page里。就会存在修改无法被感知的问题,如下图所示。

▲ 图16:为何要加@Observed

说回正题,既然要去掉这个官方的状态管理,那么就有两处改动:

  • 1) 去掉Data上的@Observed注解; 
  • 2) 在View里面不再加状态注解。

那么,如何驱动UI刷新?

正好,AttributeUpdater里面可以直接拿到attribute对象,可以通过这个对象直接设置属性,那么问题就回到了如何感知Data属性的变更。

正常情况首先想到的就是TypeScript的动态代理,即Proxy,鸿蒙的状态管理就是这么做的,其实现基于前文提到的SubscribableHandler,里面用了反射,性能不足。想要不反射,要么就字符串匹配,依次调用对应函数,既然如此,不如彻底一点,直接使用静态代理。

export class BaseData

//view的实例,由Update赋值和清理

ins?:INS

//用于刷新构造函数

updateConstructorFunc?: () =>void

private _width?: Length

private _height?: Length

//...

set width(width: Length|undefined) {

this._width = width

this.ins?.width(width) //设置属性时直接设置到view上

}

get width():Length|undefined{

returnthis._width

}

//...

最后,配套Updater的实现如下:

export class BaseUpdater> extends AttributeUpdater {

data?: DATA

constructor(data?: DATA) {

super()

this.data = data

}

//用于批量刷新所有已设置的属性,上屏或reuse时触发

updateData(data?: DATA, instance?: T): BaseUpdater {

//...

this.setUpdateFunc(this.data, ins)

if (ins) {

this.applyAttribute(ins, this.data)

this.refreshConstructor()

}

returnthis

}

//设置属性

applyAttribute(instance: CommonAttribute, data: BaseData) {

if (data.width || data.width == 0) {

instance.width(data.width)

}

if (data.height || data.height == 0) {

instance.height(data.height)

}

//...

}

}

第三版的改动总结如下:

▲ 图17:第三版改动总结

这些改动之后,通用组件内部UI层的实现也需修改:

@Component

export struct ImgTextCell {

@Consume@Watch("updateData") cellData: ImgTextCellData

rootUpdater = new RowUpdater()

imgUpdater = new ImageUpdater()

textUpdater = new TextUpdater()

aboutToAppear() {

this.updateData()

}

aboutToReuse() {

this.updateData()

}

build() {

Row() {

Image(ImageUpdater.EMPTY).attributeModifier(this.imgUpdater)

Text().attributeModifier(this.textUpdater)

}.attributeModifier(this.rootUpdater)

}

//data与updater绑定

private updateData() {

this.rootUpdater.updateData(this.cellData.root)

this.imgUpdater.updateData(this.cellData.img)

this.textUpdater.updateData(this.cellData.text)

}

}

虽然Cell内部实现变化很大,但是对业务方来说,CellData和Data的对外使用方法没有变化。

Data与Updater为何要分开。

其实这里的Cell写法看起来还是有优化空间的,比如你可能会想到,为何不把Data和Updater结合到一起,比如:

export class BaseData extends BaseUpdater{

//...

}

然后Cell的写法就可以简化成:

@Component

export struct ImgTextCell {

@Consume cellData: ImgTextCellData

build() {

Row() {

Image(ImageUpdater.EMPTY).attributeModifier(this.cellData.img)

Text().attributeModifier(this.cellData.text)

}.attributeModifier(this.cellData.root)

}

}

分两种情况讨论一下:

  • 1) 修改Data内部的值:这两种写法,都是通过AttributeUpdater内部的attribute对象进行更新,都是改那个更新哪个,没毛病; 
  • 2) 增/删/改 Data对象本身。

▲ 图18:修改 Data 本身的两种情况

6.3 升级效果

1)体积降低:

以PhotoTextCell为例,升级之后代码编译后的体积明显降低了,仅为升级前的9.3%。

可以再对比下编译后的内容。

ets源码:

build() {

Row() {

Image("").attributeModifier(this.imgUpdater)

Text().attributeModifier(this.textUpdater)

}.attributeModifier(this.rootUpdater)

}

ts产物:

initialRender() {

this.observeComponentCreation2((elmtId, isInitialRender) => {

Row.create();

Row.attributeModifier.bind(this)(this.rootUpdater);

}, Row);

this.observeComponentCreation2((elmtId, isInitialRender) => {

Image.create("");

Image.attributeModifier.bind(this)(this.imgUpdater);

}, Image);

this.observeComponentCreation2((elmtId, isInitialRender) => {

Text.create();

Text.attributeModifier.bind(this)(this.textUpdater);

}, Text);

Text.pop();

Row.pop();

}

可以看到编译产物少了很多层嵌套,代码结构清爽多了,我们的hap当时改完之后体积直接少了「十几M」。

2)性能提升:

升级之后性能也有明显提升:

  • 1) 通用组件PhotoTextCell的复用耗时从4.3ms降低到0.9ms; 
  • 2) 首页的会话列表,复用的帧率由卡顿的32帧提升到丝滑的118帧。

由于鸿蒙的动态帧率机制,118其实就是滑动时满帧。

▲ 图19:升级前后帧率对比

7、本文小结

在鸿蒙生态快速迭代的"流沙"环境下,DataList框架通过三重熵减机制构建了确定性开发范式,鸿蒙DataList的三次技术演进本质是一场对抗API不确定性的架构实践。

简单总结一下:

1) 第一版(妥协版):基于API遍历属性实现基础数据绑定,虽快速启动业务开发但存在冗余调用与性能隐患;

2) 第二版(适配版):引入AttributeModifier动态属性机制,可进行属性的动态更新,却因状态管理机制本身的性能消耗和控件包装导致代码膨胀与性能劣化;

3) 第三版(优化版):创新采用自定义状态管理,剥离包装层直接操作原生控件,结合AttributeUpdater实现静态代理与精准属性更新,使通用组件编译体积缩减至9.3%、复用耗时降低79%,帧率从32帧跃升至118帧。

三次架构升级始终贯彻MVDM分层理念,通过UI数据层的隔离,实现业务逻辑零修改适配UI层巨变。包含这三次主要的升级在内,过去一年DataList的UI层经历了十多次改动(包括API变化与对鸿蒙了解更深入而进行的性能优化)。这些变更揭示了"流沙筑城"的核心逻辑:「表层拥抱变化,中层消化冲击,核心业务层保持稳定」。

UI数据层在此场景中负责消化技术变化带来的冲击,允许团队:

  • 1) 通过接口抽象延迟具体实现决策; 
  • 2) 在知识完备后通过实现替换进行渐进式优化; 
  • 3) 保持核心业务代码的语义稳定性。

这些最终让企业微信鸿蒙团队于2024年底完成了企业微信鸿蒙NEXT第一版「100万行,600+页面」的开发,并成功发布。

至此,关于企业微信鸿蒙NEXT开发架构演进讲解完毕。

8、相关资料

[1] 微信纯血鸿蒙版正式发布,295天走完微信14年技术之路!

[2] 鸿蒙NEXT如何保证应用安全:详解鸿蒙NEXT数字签名和证书机制

[3] 开源IM聊天程序HarmonyChat:基于鸿蒙NEXT的WebSocket协议

[4] 大型IM工程重构实践:企业微信Android端的重构之路

[5] 企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等

[6] 企业微信针对百万级组织架构的客户端性能优化实践

[7] 企业微信客户端中组织架构数据的同步更新方案优化实战

[8] 微信团队分享:微信支付代码重构带来的移动端软件架构上的思考

[9] 微信团队原创分享:微信客户端SQLite数据库损坏修复实践

[10] 从客户端的角度来谈谈移动端IM的消息可靠性和送达机制

[11] 爱奇艺技术分享:爱奇艺Android客户端启动速度优化实践总结

[12] 伪即时通讯:分享滴滴出行iOS客户端的演进过程

[13] 移动端IM实践:Android版微信如何大幅提升交互性能(一)

[14] 百度公共IM系统的Andriod端IM SDK组件架构设计与技术实现

[15] 首次公开,最新手机QQ客户端架构的技术演进实践

[16] IM开发干货分享:有赞移动端IM的组件化SDK架构设计实践

[17] 马蜂窝旅游网的IM客户端架构演进和实践总结

[18] 蘑菇街基于Electron开发IM客户端的技术实践

[19] IM开发干货分享:我是如何解决大量离线消息导致客户端卡顿的

(本文已同步发布于:http://www.52im.net/thread-4812-1-1.html)

注:本文转载自juejin.cn的JackJiang的文章"https://juejin.cn/post/7493404904725102627"。版权归原作者所有,此博客不拥有其著作权,亦不承担相应法律责任。如有侵权,请联系我们删除。
复制链接
复制链接
相关推荐
发表评论
登录后才能发表评论和回复 注册

/ 登录

评论记录:

未查询到任何数据!
回复评论:

分类栏目

后端 (14832) 前端 (14280) 移动开发 (3760) 编程语言 (3851) Java (3904) Python (3298) 人工智能 (10119) AIGC (2810) 大数据 (3499) 数据库 (3945) 数据结构与算法 (3757) 音视频 (2669) 云原生 (3145) 云平台 (2965) 前沿技术 (2993) 开源 (2160) 小程序 (2860) 运维 (2533) 服务器 (2698) 操作系统 (2325) 硬件开发 (2492) 嵌入式 (2955) 微软技术 (2769) 软件工程 (2056) 测试 (2865) 网络空间安全 (2948) 网络与通信 (2797) 用户体验设计 (2592) 学习和成长 (2593) 搜索 (2744) 开发工具 (7108) 游戏 (2829) HarmonyOS (2935) 区块链 (2782) 数学 (3112) 3C硬件 (2759) 资讯 (2909) Android (4709) iOS (1850) 代码人生 (3043) 阅读 (2841)

热门文章

104
前端
关于我们 隐私政策 免责声明 联系我们
Copyright © 2020-2025 蚁人论坛 (iYenn.com) All Rights Reserved.
Scroll to Top