大厂工程化实践:如何构建可运维、高稳定性的 Flutter 混合体系

1. 增量噩梦与架构选型的"生死线"

说实话,第一次决定在几百万日活的原生App里引入Flutter的时候,我手都在抖。老板只关心两件事:新功能上线快不快?包体积会不会暴涨?

你要是直接把Flutter默认配置往工程里一扔,打包出来一看,IPA或者APK直接胖了20MB,那你离被优化也不远了。

我们得先搞清楚Flutter到底带进来了啥。

除了Dart代码本身(这其实占不了多少),大头全是Skia引擎Dart运行时还有那一堆icu数据。这时候你必须得做个外科手术式的裁剪。

架构层面的"减肥"策略

别傻乎乎地全量引入。你得在build.gradle里对ABI下手。现在的手机,基本上armeabi-v7aarm64-v8a就够了,x86这种模拟器用的架构,在Release包里必须毫不留情地砍掉。

复制代码
ndk {
    // 别贪心,保留这两个主流的就够了,其他的都是累赘
    abiFilters 'armeabi-v7a', 'arm64-v8a'
}

这里有个隐藏的坑 :如果你的原生工程里有些老旧的so库只支持v7a,而Flutter默认打出来是支持v8a的,这就会导致在某些机型上找不到so库而崩溃。对齐ABI是混合架构的第一条军规。

再者,资源混淆 (Resource Proguard)一定要上,但Flutter的资源(那些assetsfonts)有时候会被误杀,你得在白名单里把它们供起来。

还有个取巧的办法,如果是安卓,可以考虑动态下发Flutter产物 。也就是App启动时还是个纯原生的壳,检测到需要Flutter模块了,再去后台静默下载那个十几兆的so和assets。但这方案技术复杂度指数级上升,除非你对包体积敏感到了字节级别,否则慎用

2. 驯服内存巨兽:Engine共享机制的血泪史

混合开发最恶心的问题是什么?页面栈混乱

原生页面 -> Flutter页面 A -> 原生页面 -> Flutter页面 B。

如果是纯Flutter应用,这都不是事儿。但在混合栈里,每一次打开Flutter页面,如果你都new一个FlutterEngine,那简直是灾难。

你知道初始化一个Engine要多久吗?在低端机上可能要几百毫秒。更别提内存了,一个Engine吃掉几十兆,多开几个,OOM(内存溢出)教你做人。

单Engine模式 vs 多Engine模式

早期我们试过单Engine模式。全局就这一个独苗,所有Flutter页面共用它。

  • 优点:省内存,省得不像话。

  • 缺点 :太难管了!你从Flutter页面A跳到B,这中间的状态怎么存?路由栈怎么切?而且,如果我想在A页面弹个原生Dialog,再盖在B页面上,渲染树都得乱套。最致命的是,单Engine很难实现多Flutter页面的并发存在(比如底部Tab全是Flutter页面)。

后来Google推出了FlutterEngineGroup (在iOS上叫FlutterEngineGroup,安卓也是类似概念)。这玩意儿简直是救星。

FlutterEngineGroup 的魔力

它的核心逻辑是:虽然我new了多个Engine实例,但它们底层的GPU上下文、字体甚至部分Dart Isolate的堆内存是共享的

这就意味着,第二个Engine启动极快,内存占用也只是增量级的(可能就多占个几百KB到1MB)。

我们在项目里封装了一个EngineManager,专门负责派发Engine。

代码大概长这样(伪代码,领会精神):

复制代码
class FlutterCacheManager {
    // 也就是个对象池子
    private val engineGroup = FlutterEngineGroup(context)
    
    fun getEngine(engineId: String): FlutterEngine {
        // 如果池子里有,直接捞出来复用
        // 如果没有,用group生成一个新的,而不是直接new FlutterEngine
        return engineGroup.createAndRunEngine(
            context, 
            DartEntrypoint.createDefault()
        )
    }
}

实战细节提醒

虽然FlutterEngineGroup省内存,但它并没有解决数据通信 的问题。每个Engine里的Dart环境其实是逻辑隔离的(虽然物理内存共享)。你在Engine A里改了个全局变量isLogin = true,在Engine B里它可能还是false

这就逼着我们必须把状态管理下沉到原生层,或者用一种跨Engine的通信总线(后面章节我会细讲这个通信大坑)。

3. 破除隔阂:原生与Flutter通信的"高速公路"

搞定了Engine,接下来就是怎么聊天的问题。Flutter和原生就像两个语言不通的部门,MethodChannel就是中间的翻译官。

但这个翻译官有时候效率极低

MethodChannel 的性能陷阱

我看过不少实习生写的代码,把一张几兆的图片转成Base64字符串,通过MethodChannel传给原生去保存。千万别这么干!

MethodChannel在传输过程中会涉及到序列化和反序列化。String -> 二进制 -> String。这个过程在主线程执行,数据量一以前,界面立马卡顿掉帧。

那怎么传大图?

  1. 传路径:Flutter把图片存文件,把路径传给原生。原生自己去读。

  2. 共享内存(FFI):这属于高阶玩法。Dart支持FFI(Foreign Function Interface),可以直接调用C/C++代码。我们可以搞一块共享内存,Dart往里写,原生从里读。但这需要你对底层内存模型很熟,不然野指针分分钟搞崩App。

标准化通信协议设计

为了防止通信代码写成一团乱麻,我们必须定义一套协议。别今天传个JSON,明天传个XML。

我们设计了一套基于Protobuf的通信方案。为什么不用JSON?因为Protobuf体积小,解析快,而且强类型。

我们在原生层和Dart层各生成一套Model类。

通信流程变成了这样:

  1. Flutter端:构建一个UserUpdateEvent对象。

  2. 序列化成二进制流(byte array)。

  3. 通过BinaryMessenger(MethodChannel的底层实现)直接扔给原生。

  4. 原生端:收到二进制流,反序列化成Java/ObjC对象。

这样做的好处是类型安全 。你再也不用担心拼写错误把userId写成user_id导致解析失败了。

复制代码
// Dart端发送
final data = UserInfo(name: "老王", age: 39).writeToBuffer();
await platform.invokeMethod('updateUser', data);

// Android端接收
byte[] data = call.arguments();
UserInfo info = UserInfo.parseFrom(data);
// 舒服了,直接用对象,不用解Map
Log.d("TAG", info.getName());

这种做法还有一个好处:版本兼容。Protobuf天然支持字段的前向兼容,原生App没更新,H5/Flutter包更新了,也不会因为多了个字段就崩掉。

4. 页面路由管理:混合栈的"红绿灯"

路由是混合开发里最容易鬼打墙的地方。

用户点击一个按钮,是开一个新的Activity/ViewController装Flutter,还是在当前的Flutter View里push一个新的Widget?

这得看场景。

场景一:原生 -> Flutter

这个简单,原生启动一个容器Activity(我们叫它FlutterContainerActivity),在里面加载指定路由的Flutter页面。

场景二:Flutter -> Flutter

这里有分歧。

  • 方案A :直接用Flutter内部的Navigator.push。体验最好,转场动画流畅,性能开销小。

  • 方案B :通过MethodChannel告诉原生,"还要开一个Flutter页面"。原生再起一个FlutterContainerActivity

一定要选方案A! 除非你有特殊的业务需求(比如两个Flutter页面中间必须夹一个原生页面)。方案B会导致任务栈里堆满了Activity,内存爆炸,而且手势返回的体验极其割裂。

但是,方案A有个大坑:原生怎么知道你Flutter栈里还有没有页面?

当用户点安卓物理返回键时,原生Activity捕获到事件,它不知道该不该关闭自己。如果Flutter栈里还有页面,应该让Flutter回退;如果Flutter栈空了,才关闭Activity。

我们是这么解决的:重写SystemNavigator

在Flutter端监听路由栈的变化,通过MethodChannel实时同步状态给原生容器。

复制代码
// 简单的状态同步逻辑
void updateNavStatus() {
  bool canPop = Navigator.canPop(context);
  // 告诉原生容器,我现在能不能自己处理返回键
  MethodChannel('router').invokeMethod('updateCanPop', canPop);
}

原生容器在收到onBackPressed时,先判断这个标志位。如果为true,就调用Flutter的pop;如果为false,就finish自己。

这中间会有时序问题 ,比如快速点击的时候,状态还没同步过来。这时候就需要加一点点的防抖处理,或者在原生层做一个更复杂的路由代理栈(Proxy Stack)来映射Flutter内部的路由结构。这块比较复杂,涉及到Fragment的手动管理,下次有机会展开讲。

5. 统一资源管理:别让图片吃掉你的用户存量

大家做原生开发都有个习惯,切图扔进 drawable-xxhdpi 或者 Assets.xcassets 里。Flutter进场后,最直观的做法是把这些图在 pubspec.yaml 里再声明一遍,把文件拷贝到 flutter module 下。

千万别这么干!

这样做会导致同一个 ic_back.png 在APK/IPA里存在两份:一份在原生资源包里,一份在Flutter的assets里。你的包体积就是这么莫名其妙膨胀起来的。

方案一:共享原生资源(省流版)

Flutter其实提供了访问原生资源的能力,但那个API藏得有点深。

我们需要建立一个资源映射通道

  1. 图片资源 : 不要把通用图标(比如返回箭头、Loading圈、缺省图)放在Flutter里。 对于安卓,Flutter可以直接通过 AndroidAssetBundle 读取原生的assets(注意不是res/drawable,而是assets目录,这点很蛋疼)。 但更通用的做法是用PlatformView或者Texture。不过对于小图标,Texture有点杀鸡用牛刀。

    我推荐一个骚操作IconFont 统一化 。 把App里所有的单色图标做成一个 .ttf 字体文件。这个文件放在原生层。Flutter可以通过配置 fonts 路径指向原生的assets路径(仅限安卓,iOS稍微麻烦点需要Copy)。 或者干脆一点,把这几KB的字体文件在Flutter和原生各放一份。别为了这几KB搞复杂的桥接,不划算。只要保证大图不重复就行。

  2. 大图和视频:Texture(纹理)共享 这是很多大厂的杀手锏。 如果你在原生层已经有一个成熟的图片缓存库(比如Glide或SDWebImage),Flutter再去搞一套 CachedNetworkImage 又是重复造轮子,而且内存里会有两份Bitmap。

    利用 ExternalTexture

    • Flutter端:放一个 Texture 控件,它只负责展示一个ID。

    • 原生端:创建一个 SurfaceTexture (Android) 或 CVPixelBuffer (iOS),把Glide加载好的Bitmap渲染到这个Surface上。

    • 结果:内存只有一份,Flutter只是个显示器。

    但这玩意儿有坑 :Texture在某些Android机型上会有1帧的延迟,导致列表滚动时图片闪烁。如果你的列表对顺滑度要求极高,慎用;如果是视频播放器,必须用它。

6. 版本隔离:很多架构师心中的痛

这部分也是很多付费专栏不爱讲的,因为太难,容易翻车。

场景是这样的:你们公司有两个业务团队,A团队激进,想用Flutter 3.19的新特性;B团队保守,还在维护一年前的老代码,跑在Flutter 3.7上。这两个业务要集成进同一个App里。

你会发现:Dart Snapshot 不兼容Flutter Engine 的 so 库符号冲突

能不能在同一个App里跑两个版本的Flutter?

理论上能,实际上极其昂贵

你得修改Flutter Engine的C++源码,把导出的符号(Symbol)全部重命名。比如 FlutterEngine 改成 FlutterEngineV2。然后编译出两套 libflutter.so。 这带来的后果是:

  1. 包体积直接增加 5MB - 8MB(因为引擎没法复用了)。

  2. 维护成本爆炸,每次Flutter升级你都得去改源码编译引擎。

务实的解决方案:CI/CD 统一管控

除非你是微信、支付宝这种级别的超级App,否则不要尝试运行时多版本共存

真正落地的方案是基于 FVM (Flutter Version Manager) 的开发隔离,加 CI 强制对齐

  1. 开发期 : 每个业务Module仓库根目录下放一个 .fvm/fvm_config.json,指定该业务当前依赖的Flutter版本。开发人员用 fvm flutter run,互不干扰。

  2. 集成期(最关键的一步) : 在CI构建脚本里,我们要设一道卡点 。 App的主工程锁定一个Target Version(比如 3.16)。 所有业务线在合并代码前,CI会自动检测该业务代码能否在 Target Version 下编译通过。

    • 如果A业务用了3.19的新API,CI报错,打回重改,或者逼迫主工程升级。

    这看似不灵活,其实是保护系统稳定性的必要手段。 别指望技术能完美解决管理的混乱,有时候硬性的版本对齐策略比改引擎源码靠谱得多。

7. AOT编译优化:从字节抠出来的性能

到了发布这一步,你看着那个硕大的 IPA 文件发愁。别急,Dart 编译器其实帮我们留了很多后门。

Flutter 在 Release 模式下是 AOT (Ahead Of Time) 编译,产物是机器码。

1. 符号混淆与剔除 (--obfuscate)

这不仅仅是为了防反编译,更是为了减体积。 Flutter 默认打出来的包,里面带了大量的符号表(Symbol Table),方便你从堆栈信息里看到 Function name

执行命令时加上这个:

复制代码
flutter build apk --obfuscate --split-debug-info=./debug_info
  • --obfuscate:把 MyCoolWidget 变成 a,把 fetchUserData 变成 b。这能极大地缩减字符串表的大小。

  • --split-debug-info:把 mapping 文件存到本地,别打进包里。

实测效果 :在一个中型项目里,这招能立减 800KB - 1.5MB 的体积。别小看这点,对于 4G 环境下的转化率至关重要。

2. 这种代码写法会阻止 Tree Shaking

Dart 编译器自带 Tree Shaking(摇树优化),也就是把没用到的代码通过静态分析剔除掉。

但很多新手写的代码会欺骗编译器,导致无用代码被保留。

反例:

复制代码
// 这种动态反射式的写法(虽然Dart不支持完全反射,但类似的动态调用),
// 会让编译器不敢删掉某些类,因为它不知道你运行时会不会用到。
dynamic myObject = getObject();
myObject.doSomething(); 

正例:

复制代码
// 明确类型,编译器就能顺着调用链分析,
// 发现某个分支永远走不到,直接剪掉。
User myUser = getObject();
if (false) {
    // 这段代码会被完美剔除
    myUser.doSomething(); 
}

还有,多用 constconst Icon(Icons.add) 不仅仅是内存优化,它在编译阶段就确定了结构,编译器更容易做常量折叠和死代码消除。

3. 针对 Android 的终极压缩:动态下发 so

如果你们老板对包体积有强迫症,你可以尝试动态加载 Flutter Engine

原理是:APK 里不放 libflutter.soapp.so。用户启动 App 后,后台下载这两个文件,通过 System.load() 手动加载。

技术难点

  • 你得处理下载失败、文件校验、加载时机(必须在 Flutter 页面启动前完成)。

  • Google Play 可能不允许这种下载可执行二进制代码的行为(虽然 libflutter.so 算是库,但 app.so 是业务代码,在 iOS 上绝对是红线,Android 国内渠道目前还能玩)。

iOS 警告 : 千万别在 iOS 上尝试动态下发 App.framework 或者 Dart AOT 产物。苹果审核查到一个封一个,理由是 "Downloadable Code"。iOS 只能老老实实做编译时优化。

8. 避坑指南:那些官方文档没告诉你的事

最后给几个我在深夜排查Bug时总结的血泪经验:

  1. 软键盘遮挡问题 : 在原生里,adjustResize 配合 ScrollView 就能搞定。但在混合栈里,Flutter 的 View 有时候拿不到正确的高度变化通知,导致输入框被键盘盖住。 解法 :不要依赖 Scaffold 自带的 resizeToAvoidBottomInset。最好自己监听 WidgetsBinding.instance.window.viewInsets.bottom,用 AnimatedPadding 把界面顶上去。这样控制权在你手里。

  2. 字体粗细渲染不一致 : 设计师经常吼:为什么 iOS 上的"粗体"比 Android 上要细一点?或者行高不一样? 这是因为 Flutter 在不同平台调用的默认字体渲染引擎不同。 解法 :引入一套自定义的数字字体(如 Roboto 或 PingFang 的特定字重),强制指定 fontFamily。不要用系统默认字体,否则 UI 还原度永远做不到 100%。

  3. 列表滚动性能 : 如果在 Release 包里列表滚动还是卡,多半是因为你在 itemBuilder 里做了耗时操作(比如日期格式化、JSON解析)。 记住build 方法每秒可能执行 60 次。把所有数据处理逻辑挪到 build 之外,传进来的应该是一个纯粹的 ViewModel,直接取值,不要计算。

9. 状态同步:打破"精神分裂"的僵局

混合开发最大的痛点之一,就是状态割裂

原生模块里用户登录了,Token 存在 SharedPreferences 里。然后用户点进了一个 Flutter 页面,发起网络请求,结果 Flutter 居然报 401 未授权?

因为 Flutter 的内存里压根不知道你登录了!这就好比你左手买了彩票中奖了,右手还在那里苦逼地搬砖,因为大脑没把信号传过去。

拒绝"拉取式",拥抱"推送式"

很多新手喜欢这么写:Flutter 页面每次 init 的时候,去调用 MethodChannel 问原生:"哎,现在的 Token 是多少?现在的 App 主题是黑夜模式吗?"

这是个大坑。

  1. :异步调用,还要等原生回话,你的页面可能已经渲染完了,用户会看到界面从"未登录"闪烁变成"已登录",体验极差。

  2. :如果用户在原生层切换了账号,Flutter 还在后台活着,没销毁,也没人通知它更新,等你切回来,显示的还是上一个人的数据。这就是典型的数据一致性Bug,测试能给你提一堆单子。

架构方案:状态镜像(State Mirroring)

我们需要建立一个单向数据流 的机制。通常情况下,原生是单一信源(Source of Truth)

我们设计了一套基于 EventChannel 的广播机制,把它想象成一条跨端的消息总线

  1. 原生端(发送方) : 原生层封装一个 StateBridge。只要原生业务里发生关键状态变更(登录/注销、切换语言、切换环境、会员升级),立马向 EventChannel 发送一条广播。

    复制代码
    {
      "type": "USER_STATUS_CHANGE",
      "payload": {
        "isLogin": true,
        "userId": "8848",
        "vipLevel": 10
      }
    }
  2. Flutter端(接收方) : 在 main.dart 的入口处,我们不仅初始化 UI,还要初始化一个全局状态监听器。 这个监听器收到 EventChannel 的消息后,不直接操作 UI,而是去更新 Flutter 内部的状态管理库(比如 Provider、Bloc 或 Riverpod)。

    代码大概是这样的味道:

    复制代码
    // 在 main() 里启动监听
    NativeBridge.stream.listen((event) {
      if (event['type'] == 'USER_STATUS_CHANGE') {
        // 更新全局 UserProvider
        context.read<UserProvider>().update(event['payload']);
      }
    });

    这样一来,Flutter 里所有的页面,只要监听了 UserProvider,就会自动刷新。哪怕 Flutter 页面在后台,状态也已经静默同步好了。

初始化的"真空期"怎么破?

有个细节特别容易被忽略:EventChannel 建立连接是需要时间的。 如果在 Flutter 引擎刚启动的那几百毫秒里,原生发了消息,Flutter 还没来得及监听,这消息就丢了。

补救措施 : 在 FlutterEngine 启动时(也就是原生代码里 startExecution 的时候),把当前的全量核心状态 (Token、用户信息、配置),直接通过 DartEntrypointArgs 塞给 Dart 的 main 函数;或者在 Dart 跑起来的第一行代码,主动去原生拉一次"快照(Snapshot)",以此作为初始值,随后再切换为监听模式。

10. 工程化交付:如何让原生同事不恨你

如果你让原生开发的同事为了跑你的代码,去安装 Flutter 环境、配 Dart SDK、还要解决各种 gradle 依赖冲突,信不信他们下班会堵你?

混合开发的最高境界是:原生开发人员根本感觉不到 Flutter 的存在。

我们要把 Flutter 伪装成一个普通的第三方库,就像集成 OkHttpAFNetworking 那样简单。

Android:AAR 产物化

不要让原生工程直接 include Flutter 的源码工程。 我们要搞个 CI 脚本,自动把 Flutter Module 编译成 .aar 文件,然后发布到公司的私有 Maven 仓库。

脚本大致逻辑:

  1. flutter build aar --build-number=1.0.X

  2. 解析生成的 pom 文件,获取依赖关系。

  3. mvn publish 上传到 Nexus/Artifactory。

原生工程的 build.gradle 只需要写一行:

复制代码
dependencies {
    // 就像引用一个普通库一样
    implementation 'com.mycompany.app:flutter_module:1.0.45'
}

这样,原生同事根本不需要安装 Flutter SDK。你更新了代码,发个新版本号,他们 Sync 一下 Gradle 就完事了。

iOS:CocoaPods 与 XCFramework

iOS 这边稍微麻烦点。Flutter 编译产物是一堆 App.framework, Flutter.framework

推荐使用 CocoaPods 私有库方案。

  1. CI 脚本执行 flutter build ios-framework --output=./FlutterBuild

  2. 这会生成 .xcframework 文件(支持模拟器和真机架构)。

  3. 自动生成一个 FlutterModule.podspec 文件。

  4. 原生工程的 Podfile 指向这个私有库。

注意版本号管理 : 每次发布,一定要严格遵循 Semantic Versioning(语义化版本)。别随意覆盖同一个版本号的产物,否则 Gradle/CocoaPods 的缓存机制会让你怀疑人生------明明代码改了,为什么跑起来还是旧的?

11. 监控与崩溃:黑盒里的求救信号

Flutter 代码跑飞了,一般不会直接导致 App 闪退(Crash),而是出现红屏 (Debug模式)或灰色卡死(Release模式)。

但这就完了吗?太天真了。 Flutter 引擎底层是 C++,Skia 也是 C++。如果引擎本身出了 bug,或者 Dart 通过 FFI 调用 C++ 代码写炸了,那 App 会直接Signal 11 (SIGSEGV) 暴毙。

Dart 层的异常捕获

别只靠 try-catch。你要在最顶层撒一张大网。

runZonedGuarded 包裹你的 runApp

复制代码
void main() {
  // 捕捉 Flutter 框架内的错误(构建失败、渲染溢出)
  FlutterError.onError = (FlutterErrorDetails details) {
    // 过滤掉那该死的 "RenderFlex overflowed..." 
    // 毕竟只有像素溢出不影响功能时,没必要报警
    if (details.exception is! RenderFlexOverflowException) {
      CrashReporter.report(details.exception, details.stack);
    }
  };

  // 捕捉 Dart 异步错误(Future 未捕获异常)
  runZonedGuarded(() {
    runApp(MyApp());
  }, (error, stackTrace) {
    CrashReporter.report(error, stackTrace);
  });
}

符号化(Symbolication):让天书变人话

你在后台看到的 Dart 报错堆栈通常是这样的: File "abs.dart", line 1, column 4532 in method x

因为你开了混淆(还记得上一章说的 AOT 优化吗?),这些堆栈信息全是乱码,根本没法查。

必须建立符号表还原流程

  1. 每次 CI 打包时,保存 --split-debug-info 生成的符号文件。一定要把这个文件和当次构建的 commit hash 对应起来存档

  2. 当监控平台(比如 Sentry, Bugly, 或者自研平台)收到报错时,利用 flutter symbolicate 命令,配合存档的符号文件,把乱码还原成源码行号。

很多团队不做这一步,结果线上出了 bug 只能靠猜,甚至靠"通灵"。

12. 混合架构的终局思考

写到这里,这套混合架构方案的脉络应该很清晰了。

我们在做什么?其实就是在不破坏原生应用稳定性的前提下,偷渡 Flutter 的生产力

  • ABI 过滤和资源去重,是为了讨好用户(包体积)。

  • Engine 复用,是为了讨好手机内存。

  • Protobuf 通信,是为了讨好 CPU 和网络。

  • Maven/Pod 私有库交付,是为了讨好原生开发同事。

  • 符号化监控,是为了讨好半夜被叫起来修 Bug 的你自己。

千万别迷信"Flutter 一统天下"的鬼话。在相当长的一段时间里,混合开发才是常态。 我们作为技术人,不要为了用新技术而用新技术。如果一个页面交互极其复杂,动画极多,且不需要热更新,直接用原生写可能才是对负责;如果一个页面全是列表展示,运营活动多,这时候上 Flutter 才是降维打击。

架构没有最好的,只有最合适的。

这套方案也是我们在踩了无数个坑(包括且不限于:iOS 14 上的 Metal 渲染崩溃、安卓各路厂商 ROM 的奇葩兼容性、键盘顶起页面的 100 种失败姿势)之后总结出来的。

希望这些干货能帮你的 KPI 稍微好看一点,或者至少,让你在技术评审会上能把对方怼得心服口服。

相关推荐
Hexene...1 小时前
【前端Vue】如何快速直观地查看引入的前端依赖?名称版本、仓库地址、开源协议、作者、依赖介绍、关系树...(Node Modules Inspector)
前端·javascript·vue.js
fanruitian1 小时前
div水平垂直居中
前端·javascript·html
旭久1 小时前
react+antd实现一个支持多种类型及可新增编辑搜索的下拉框
前端·javascript·react.js
摘星编程1 小时前
用React Native开发OpenHarmony应用:Loading加载状态组件
javascript·react native·react.js
一起养小猫2 小时前
Flutter for OpenHarmony 进阶:异步编程与同步机制深度解析
flutter·harmonyos
Можно2 小时前
从零开始:Vue 框架安装全指南
前端·javascript·vue.js
阿蒙Amon2 小时前
TypeScript学习-第9章:类型断言与类型缩小
javascript·学习·typescript
福大大架构师每日一题2 小时前
agno v2.4.7发布!新增Else条件分支、AWS Bedrock重排器、HITL等重大升级全解析
javascript·云计算·aws
向哆哆2 小时前
Flutter × OpenHarmony 跨端开发:高校四六级报名管理系统中的“常见问题”模块实现解析
flutter·开源·鸿蒙·openharmony·开源鸿蒙