跨平台UI组件前端移动开发【免费下载链接】ValdiValdi is a cross-platform UI framework that delivers native performance without sacrificing developer velocity.项目地址https://gitcode.com/gh_mirrors/val/Valdi点击查看免费下载导读custom-view是 Valdi 提供的一等公民 TSX 元素它允许你在 Valdi 渲染的 UI 树中直接注入 iOSUIView、AndroidView、macOSNSView与 WebDOM 元素四端原生视图从而复用已经实现好的平台专属代码而不必用 Valdi 组件重新实现一遍。读完本文你将掌握 ViewFactory 注入与平台类名映射两条集成路径、Attributes Binding 属性绑定机制、测量measure委托的配置方法以及 Web 端工厂注册的完整实操流程。一、什么是custom-view跨平台原生视图注入Valdi 是一个跨平台 UI 框架但它并不要求所有界面元素都必须用 Valdi 组件编写。当某个视图非常复杂、且已经在原生侧实现例如系统级控件、图像缓冲区绘制、文本字形渲染等时通过custom-view元素可以把它直接嵌入 Valdi 的功能页面中四个平台均支持平台承载的原生视图类型iOSUIViewAndroidViewmacOSNSViewWebDOM 元素这种集成方式的核心价值在于复用平台专属代码与其为某个复杂控件编写一份新的跨平台组件不如直接注入平台上已经成熟的原生实现。Valdi 官方文档 core-component.md 中也将custom-view定位为「直接在模板中包含原生视图」的扩展手段glossary.md 将其定义为「为在 Valdi 模板中使用而包装起来的原生 iOS 或 Android 视图」internals-native-integration.md 则指出该元素允许开发者将任意原生视图注入 Valdi 树。在 TSX 类型系统中custom-view是一个注册在JSX.IntrinsicElements中的内置元素其类型定义位于 JSX.tsexport interface CustomView extends View { iosClass: string; androidClass: string; } export interface DeferredCustomView extends View { viewFactory: ViewFactory; }可以看到custom-view支持两种形态一种是携带iosClass/androidClass等平台类名见下文「类映射」另一种是携带viewFactory直接引用一个原生视图工厂推荐方式。接下来我们先介绍 ViewFactory 路线。二、使用 ViewFactory 注入自定义视图2.1 TypeScript 侧声明在 TSX 中custom-view接收一个viewFactory属性该属性来自组件的context用于告诉 Valdi 应该实例化哪个原生视图export interface MyContext { myCustomViewFactory: ViewFactory; // This will tell valdi which native view to use } export class MyComponent extends Component{}, MyContext { onRender() { view backgroundColorlightblue custom-view viewFactory{this.context.myCustomViewFactory} myAttribute{42}/ /view } }ViewFactory是一个跨语言桥接的 opaque 类型其声明位于 ViewFactory.d.ts// NativeInterface({marshallAsUntyped: true, ios: SCValdiViewFactory, iosImportPrefix: valdi_core, android: com.snap.valdi.ViewFactory}) export interface ViewFactory { // this type tag only exists to enforce stronger TypeScript compiler guarantees __tag: ViewFactory; }注意NativeInterface注释它表明 TSX 侧的ViewFactory在 iOS 对应SCValdiViewFactory来自valdi_core在 Android 对应com.snap.valdi.ViewFactory。这个 opaque 标签在 JS 侧不产生真实值仅用于在 TypeScript 编译期保证类型安全——你无法在 TS 侧随意伪造一个ViewFactory它只能从原生运行时runtime创建而来。2.2 Android 侧创建 ViewFactory在 Kotlin 中通过runtime.createViewFactory(...)创建工厂把自定义视图类、构造逻辑和属性绑定器一并交给 Valdi// Create a ViewFactory, that will instantiate a MyCustomView under the hood. val myCustomViewFactory runtime.createViewFactory(MyCustomView::class.java, { context - MyCustomView(context, this.myViewDependencies) }, MyCustomViewAttributesBinder(context)) // Pass the viewFactory to the context val context MyContext() context.myCustomViewFactory myCustomViewFactory // Create the view with the context val view MyComponent.create(runtime, null, context)2.3 iOS 侧创建 ViewFactory在 Objective-C 中使用makeViewFactoryWithBlock:attributesBinder:forClass:创建工厂block 中返回视图实例// Create a ViewFactory, that will instantiate a MyCustomView under the hood. idSCValdiViewFactory myCustomViewFactory [runtime makeViewFactoryWithBlock:^UIView *{ return [[MyCustomView alloc] initWithMyDependencies:self.myViewDependencies]; } attributesBinder:nil forClass:[MyCustomView class]]; // Pass the viewFactory to the context MyContext *context [MyContext new]; context.myCustomViewFactory myCustomViewFactory; // Create the view with the context MyComponent *view [[MyComponent alloc] initWithRuntime:runtime viewModel:nil componentContext:context];2.4 关于延迟实例化DeferredCustomView这个名字揭示了 ViewFactory 路线的另一个能力工厂只有在视图真正需要被 inflate 时才会被调用原生视图实例不会在组件创建时立即产生。这为「视图加载成本较高、希望按需创建」的场景提供了天然的延迟语义也是官方文档建议优先使用ViewFactory而非类映射的原因之一详见第五节。三、Attributes Binding为自定义视图声明可配置属性仅仅声明一个视图类通常是不够的——自定义视图要真正可用必须能被 Valdi 的属性系统配置。例如Label暴露value、font等属性如果你做一个SliderView可能希望暴露progress属性来改变滑杆位置。Attributes Binding属性绑定就是 iOS / Android 平台声明「某个视图类支持哪些属性」的过程。其运行时机非常关键当 Valdi 首次 inflate 某个视图类的实例时会调用原生侧的绑定注册代码这个过程是懒加载的并且每个视图类只会发生一次与由哪个组件触发 inflate 无关。以 iOS 侧为例这个懒加载机制在 SCValdiViewManager.mm 中有清晰体现Valdi 的 C 层ViewManager::bindAttributes通过selector(bindAttributes:)在视图类上查找方法实现并通过比较methodForSelector:与父类是否相同来确认该类是否真的覆写了bindAttributes:避免误触发继承的方法随后包装成SCValdiAttributesBinder调用注册逻辑。3.1 测量Measure机制让视图按内容自适应大小有些视图需要根据自身属性自动调整尺寸——仍以Label为例改变font或文本后视图应当变大变小。为此Valdi 提供两种测量委托setPlaceholderViewMeasureDelegate声明一个「占位视图实例」测量时把属性应用到它身上然后调用 iOS 的sizeThatFits:或 Android 的onMeasure()setViewMeasureDelegateAndroid 为setMeasureDelegate显式返回一个尺寸。如果两者都定义了setViewMeasureDelegate优先。测量方法在视图类侧对应 iOS 的sizeThatFits:与 Android 的onMeasure()使用时请确保视图正确实现这些方法。[!NOTE]并非所有自定义视图都需要测量。当视图尺寸能从 TSX 侧的布局属性直接推断时原生测量方法根本不会被调用。例如下面这个视图宽度相对父视图已测量、已知定义高度静态为 100view width{100%} height{100} /这种情况下上述原生测量方法不会被调用。3.2 Android实现AttributesBinder在 Android 上需要实现com.snap.valdi.attributes.AttributesBinder接口并把它注册到 ViewFactory 上参见上文 2.2 节MyCustomViewAttributesBinder(context)参数。注意接口的实例要通过RegisterAttributesBinder注解标注Valdi 运行时才能发现它——该注解定义于 RegisterAttributesBinder.kt接口本体在 AttributesBinder.kt而真正承载绑定能力的上下文类是 AttributesBindingContext.kt它提供bindUntypedAttribute、bindDoubleAttribute等便捷方法以及底层的getBoundAttributeId用于把属性名解析为编译期整数 ID。import com.snap.valdi.attributes.RegisterAttributesBinder import com.snap.valdi.attributes.AttributesBinder class MyCustomView(context: Context) : FrameLayout(context) {} // Make sure to add the RegisterAttributesBinder annotation so that // the Valdi runtime can find it. RegisterAttributesBinder class MyCustomViewAttributesBinder(context: Context): AttributesBinderMyCustomView { override val viewClass: ClassMyCustomView get() MyCustomView::class.java private fun applyMyAttribute( view: MyCustomView, attributeValue: Double, animator: ValdiAnimator? ) { view.myAttribute attributeValue } private fun resetMyAttribute( view: MyCustomView, animator: ValdiAnimator? ) { view.myAttribute 0 } override fun bindAttributes( attributesBindingContext: AttributesBindingContextMyCustomView ) { // Define an attribute attributesBindingContext.bindDoubleAttribute( myAttribute, false, // this attribute will not invalidate the layout of the view this::applyMyAttribute, // called when a new attribute value is set on the view this::resetMyAttribute // called when the attribute becomes undefined ) // This is optional and will make the view measureable attributesBindingContext.setPlaceholderViewMeasureDelegate(lazy { ValdiDatePicker(context).apply { layoutParams ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) } }) // This is optional and will make the view measureable attributesBindingContext.setMeasureDelegate(object : MeasureDelegate { override fun onMeasure( attributes: ViewLayoutAttributes, widthMeasureSpec: Int, heightMeasureSpec: Int, isRightToLeft: Boolean ): MeasuredSize { return MeasuredSize(1000, 1000) } }) } }语义与 iOS 一致我们向 Valdi 声明「我能处理myAttribute这个 double 类型的属性」并提供 apply / reset 两组回调。bindDoubleAttribute的第二个参数invalidateLayoutOnChange含义如下传false属性变化不影响视图的固有尺寸不会触发布局重算传true告诉 Valdi 属性变化可能导致视图固有尺寸改变典型例子是Label的font——字体变大后 Label 会变大。每当此类属性变化时布局计算一定会被触发。3.3 iOS覆写 (void)bindAttributes:在 iOS / macOS 上只需在自定义视图类中覆写类方法 (void)bindAttributes:(SCValdiAttributesBindingContext *)并通过传入的 binding context 注册属性与回调implementation MyCustomView {} - (void)valdi_applyMyAttribute:(double)attributeValue { self.myAttribute attributeValue; } (void)bindAttributes:(SCValdiAttributesBindingContext *)bindingContext { // Define an attribute [bindingContext bindAttribute:myAttribute invalidateLayoutOnChange:NO withDoubleBlock:^(MyCustomView *myCustomView, double attributeValue, SCValdiAnimator *animator) { // called when a new attribute value is set on the view [myCustomView valdi_applyMyAttribute:attributeValue]; } resetBlock:^(MyCustomView *myCustomView, SCValdiAnimator *animator) { // called when the attribute becomes undefined [myCustomView valdi_applyMyAttribute:0]; }]; // This is optional and will make the view measureable [attributesBinder setPlaceholderViewMeasureDelegate:^UIView *{ return [MyCustomView new]; }]; // This is optional and will make the view measureable (will be prioritized if setPlaceholderViewMeasureDelegate is also defined) [attributesBinder setMeasureDelegate:^CGSize(idSCValdiViewLayoutAttributes attributes, CGSize maxSize, UITraitCollection *traitCollection) { CGSize newSize CGSizeMake(1000, 1000); return newSize; }]; } end两个关键参数说明animator参数当属性变化被预期带动画时传入。SCValdiAnimator提供基于CoreAnimation的动画方法如果你的属性不可动画忽略它即可。invalidateLayoutOnChange参数与 Android 端语义完全一致用于告知 Valdi 该属性变化是否会改变视图的固有尺寸font类属性应传YES。3.4 iOS把Observable绑定到视图属性如果你的属性是一个可观察流Observable需要把桥接后的 Observable 从 TSX 侧传递过来。TSX 侧把原始 Observable 转换为桥接对象后传入在 TSX 层将属性写为observable{convertObservableToBridgeObservable(yourObservable)}在 iOS 层使用bindAttribute withUntypedBlock:注册名为observable的属性并通过 marshaller 从非类型化的实例还原出SCValdiBridgeObservable再转成SCObservableSCObservableNSString *observable; SCValdiMarshallerScoped(marshaller, { NSInteger objectIndex SCValdiMarshallerPushUntyped (marshaller, untypedInstance); SCValdiBridgeObservable *bridgeObservable [SCValdiMarshallableObjectRegistryGetSharedInstance() unmarshallObjectOfClass:[SCValdiBridgeObservable class] fromMarshaller:marshaller atIndex:objectIndex]; observable [bridgeObservable toSCObservable]; });这段代码演示了 Valdi 的 marshalling 机制如何把非类型化的桥接对象安全地还原为类型化对象——untypedInstance即withUntypedBlock:回调中拿到的原始值。仓库中SCValdiViewFactory/ marshaller 相关的核心实现位于 valdi/src/valdi 的 iOS 目录如 SCValdiViewManager.h 中bindAttributes的 C 声明。四、何时用原生视图何时用 Valdi 组件某些场景下比如SliderView用 Valdi 组件实现可能更简单且功能等价。官方文档给出了三条「应该选择原生视图类」的理由Valdi 难以实现的功能例如在屏幕上绘制图像缓冲区、绘制文本字形这些必须使用 iOS / Android 的平台 API——此时原生Image、Label视图类比 Valdi 组件更合适已存在满足需求的视图类原生侧已经有实现好的、功能恰好满足需求的视图类直接复用性能敏感的逻辑视图类中带有性能开销大的逻辑放在 JavaScript 引擎中求值太慢应当放在原生侧。从源码结构看Valdi 内置的Label、ImageView、BlurView、GlassView等元素正是通过 NativeTemplateElements.ts 声明的模板元素它们在四个平台各自映射到原生视图实现这与custom-view的理念一脉相承——只是这些是框架内置的而custom-view把同等的注入能力开放给了应用开发者。五、类映射通过平台类名直接引用视图类除 ViewFactory 外TSX 中还可以直接通过平台类名引用任意视图类官方文档提示大多数情况下 ViewFactory 更简单、更安全。要使视图类能被 Valdi 实例化它必须具备可用的构造器iOS / macOSinitWithFrame:Androidinit(context: Context)。如果目标类没有这些构造器可以自行编写一个包装视图类。若视图类构造还需要额外的原生依赖Valdi 推荐的做法是把依赖通过属性注入。另外如果一个视图类要在多个 feature 间复用考虑把原生视图类抽象封装进一个组件中。5.1 四个平台类属性custom-view支持四个平台专属的类名属性AttributePlatformExampleiosClassiOSSliderViewandroidClassAndroidcom.snap.valdi.SliderViewmacosClassmacOSSliderViewwebClassWebslider-view平台回退fallthrough规则macOS在未指定macosClass时回退使用iosClass。也就是说如果 iOS 与 macOS 共用同一个类两者都基于 Objective-C这是常见情况只需写iosClassWeb使用webClass在WebViewClassRegistry中查找已注册的工厂只需为应用实际目标的平台指定属性无需全部填写。5.2 TypeScript 完整示例export interface SliderViewModel { progress: number; } export class Slider extends ComponentSliderViewModel { onRender() { custom-view iosClassSliderView androidClasscom.snap.valdi.SliderView macosClassMacOSSliderView webClassslider-view progress{this.viewModel.progress} / } }如果 macOS 与 iOS 视图类相同省略macosClass// macOS will automatically use SliderView (the iosClass) custom-view iosClassSliderView androidClasscom.snap.valdi.SliderView progress{this.viewModel.progress} /5.3 Android 注意事项R8 / ProGuard 告警// We can then create the view val view MyFeature.create(runtime, null, null)[!Warning] Valdi 运行时使用 JVM 的反射 API 解析视图类名。传入的 Android 类名必须在 release 构建中保持稳定否则该机制会失效。在 Snapchat 的 Android release 构建中R8 优化器会混淆、重命名类可能破坏 Valdi 中通过类名引用的自定义视图。解决办法在 Kotlin 中使用Keep注解或配置proguard-rules.pro文件确保视图类不被重命名且不会从 APK 中被移除。5.4 iOS 与 macOS 侧iOS 侧只需在视图类中定义属性绑定并正常创建组件// When we create the slider-view, wed need to define the attribute binding implementation SliderView {} (void)bindAttributes:(SCValdiAttributesBindingContext *)bindingContext { // Define my sliders attributes } end // We can then create the view MyFeature *view [[MyFeature alloc] initWithRuntime:runtime viewModel:nil componentContext:nil];macOS 与 iOS 机制相同。若 macOS 视图与 iOS 共用类名iosClass回退规则会自动处理仅在 macOS 视图不同时才单独定义macosClass// macOS-specific view (e.g., using NSPopUpButton instead of UIPickerView) implementation MacOSSliderView {} (void)bindAttributes:(SCValdiAttributesBindingContext *)bindingContext { // Define macOS-specific attributes } end5.5 Web 端工厂注册模式Web 端的自定义视图采用工厂注册模式注册一个接收 DOM 容器、返回属性处理器的工厂函数// In your web polyglot module (e.g., web/src/MyWebViews.ts) interface AttributeHandler { changeAttribute(name: string, value: unknown): void; } type ViewFactory (container: HTMLElement) AttributeHandler; function createSliderFactory(): ViewFactory { return (container: HTMLElement): AttributeHandler { const slider document.createElement(input); slider.type range; container.appendChild(slider); return { changeAttribute(name: string, value: unknown): void { if (name progress typeof value number) { slider.value String(value * 100); } }, }; }; } // Class names must match the webClass attributes in the corresponding TSX components. export const webPolyglotViews: Recordstring, ViewFactory { slider-view: createSliderFactory(), };工厂函数接收一个容器 DOM 元素返回带changeAttribute(name, value)方法的对象用于接收 Valdi 渲染器发来的属性更新。这套机制在源码中的落地非常完整WebViewClassRegistry.ts 定义了WebViewClassFactory、WebViewClassAttributeHandler等类型并用挂在globalThis上的Map__valdiWebViewClassRegistry实现跨 chunk 共享的单例注册表registerWebViewClass(className, factory)用于注册getWebViewClassFactory(className)用于查询。而 WebValdiCustomView.ts 是 Web 端custom-view的实际渲染类它收到webClass后查询注册表并调用工厂创建 DOM 内容在webClass尚未到达时先缓冲buffer其他属性待工厂创建完成后再统一 flush若类名未注册则渲染一个占位标签。注意androidClass/iosClass/macosClass在 Web 端会被直接忽略。注册 Web 工厂的 Bazel 配置要注册 Web 工厂需要创建一个ts_project绝不能使用filegroup并将其加入valdi_module的web_deps。ts_project要求transpiler tsc以及独立的web/tsconfig.jsonload(aspect_rules_ts//ts:defs.bzl, ts_project) ts_project( name my_web_views, srcs glob([ web/**/*.ts, src/**/*.d.ts, # include if web code imports module type declarations ], exclude [ web/**/*.d.ts, # avoid TS5055 output collision with composite ]), allow_js True, composite True, transpiler tsc, tsconfig web/tsconfig.json, ) valdi_module( name my_module, # ... web_deps [:my_web_views], )web/tsconfig.json应当是独立的配置不要 extends 模块级 tsconfig{ compilerOptions: { target: ES2016, module: commonjs, strict: true, composite: true, allowJs: true, lib: [dom, ES2019] } }ts_project中使用glob时排除web/**/*.d.ts是为了避免 composite 构建下与src/**/*.d.ts的 TS5055 输出冲突allow_js、composite、transpiler tsc均是 Web polyglot 模块可用的必要配置。六、总结与选型建议集成路线TSX 侧写法适用场景ViewFactory 注入custom-view viewFactory{...} /需要运行时控制实例化、避免反射/类名混淆、大多数场景的推荐方案平台类名映射custom-view iosClass... androidClass... webClass... /快速接入已有原生类四端类名已知且稳定Android 注意 R8 混淆无论走哪条路线有两件事是自定义视图「可用」的前提一是通过Attributes BindingAndroid 的AttributesBinderRegisterAttributesBinderiOS 的bindAttributes:声明属性及 apply / reset 回调并合理设置invalidateLayoutOnChange二是按需实现测量委托setPlaceholderViewMeasureDelegate/setMeasureDelegate对应sizeThatFits:与onMeasure()让视图能在 TSX 侧未显式给定尺寸时正确自适应。从仓库证据看这条能力链在四端都有完整实现TSX 类型层在 JSX.ts 注册custom-view元素Web 端有 WebViewClassRegistry.ts 与 WebValdiCustomView.tsAndroid 端属性绑定基础设施在 AttributesBindingContext.ktiOS 端类方法发现与懒加载绑定逻辑在 SCValdiViewManager.mm。你可以顺着这些文件继续深入阅读也可以在 valdi/src/java/com/snap/valdi/attributes/impl 下查看内置控件如EditTextAttributesBinder、AnimatedImageViewAttributesBinder等的真实 Binder 实现作为参考范本。赞分享跨平台UI组件前端移动开发【免费下载链接】ValdiValdi is a cross-platform UI framework that delivers native performance without sacrificing developer velocity.项目地址https://gitcode.com/gh_mirrors/val/Valdi点击查看免费下载相关推荐Valdi custom-view 自定义原生视图接入指南类名解析、viewFactory 与跨平台属性绑定Valdi custom view 自定义原生视图接入指南类名解析、viewFactory 与跨平台属性绑定 导读 本文面向需要在 Valdi 组件中嵌入平跨平台UI组件前端移动开发Valdi Polyglot 模块开发指南用 BUILD.bazel 与 TSX 集成 Android / iOS / macOS / Web 四端原生实现Valdi Polyglot 模块开发指南用 BUILD.bazel 与 TSX 集成 Android / iOS / macOS / Web 四端原生实现跨平台UI组件前端移动开发Fresco 自定义视图实战用 DraweeHolder / MultiDraweeHolder 在自定义 View 中承载图片渲染Fresco 自定义视图实战用 DraweeHolder / MultiDraweeHolder 在自定义 View 中承载图片渲染 Fresco 的 Dra移动开发图像处理创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
