本文介绍了一次换肤需求经历。
背景
产品想要在一些大型活动时在社区里换肤,换换背景图、背景色这些,其实和之前适配多业务视觉风格有相似之处,当时也把通用资源整理归类,直接同名同类型替换即可。
在这个场景下虽然可以无需开发通过热更来替换资源,但之后每次活动上线都要开发配合发热更包,可能还要支持除图以外的其他配置项,在后期也会有隐形开发成本。所以最好前端提前预埋配置项,之后运营直接在管理端配置,可一劳永逸。
方案
和产品梳理发现配置项高达40+个,时间原因先做一部分,如此在前期就要考虑好如何设计方便后续快速开发上线。
首先需要新增一个皮肤配置管理器SkinConfigMgr
用来拉取和保存配置内容,由于配置拉取是异步的,为了能及时刷新界面这里配置都是用ValueNotifier
持有,例如有这样几个配置项:
class SkinConfigMgr {
static SkinConfigMgr? _instance;
SkinConfigMgr._();
static SkinConfigMgr get instance => _instance ??= SkinConfigMgr._();
ValueNotifier<String> mainNavBgColor = ValueNotifier(""); // 首页顶部导航栏背景色
ValueNotifier<String> mainBgImg = ValueNotifier(""); // 首页背景图
ValueNotifier<String> mainBgColor = ValueNotifier(""); // 首页背景色
// 初始化
void initSkinConfigs(ClientThemeSetting settings) {
mainNavBgColor.value = settings.mainNavBgColor;
mainBgImg.value = settings.mainBgImg;
mainBgColor.value = settings.mainBgColor;
}
}
因为是全局配置,要在app启动时就拉取请求,然后调用初始化即可SkinConfigMgr.instance.initSkinConfigs(apiReply.themeSetting)
,避免并发多次请求考虑和其他请求做合并。
然后需要适配图片组件,这里尽可能保持组件的通用性以及降低外部使用成本,兼容了各种默认图样式,外部只要多传入一个图片配置项和是否需要开启换肤开关即可。换肤图片组件SkinImageView
具体如下:
class SkinImageView extends StatelessWidget {
final ValueNotifier<String> imgNotifier; // from SkinConfigMgr
final String defImgUrl; // 原图片路径
final double size; // 图片尺寸,这里填 width,若 height 和 width 不一致还需要给下 height
final bool fromNetwork; // 是否网络图
final double? height;
final String? package;
final BoxFit? fit;
final IconData? icon;
final bool supportSkinConfig;
SkinImageView(
this.imgNotifier,
this.defImgUrl,
this.size,
this.fromNetwork, {
this.height,
this.package,
this.fit,
this.icon,
this.supportSkinConfig = true,
});
Widget _getDefView() {
if (defImgUrl.isEmpty) {
return SizedBox.shrink();
}
// icon图标
if (icon != null) {
return Icon(icon, size: size);
}
// 网络图
if (fromNetwork) {
return ImageView(
url: defImgUrl,
width: size,
height: height ?? size,
fit: fit,
);
}
// 本地svg
if (defImgUrl.toLowerCase().endsWith("svg")) {
return SvgPicture.asset(
defImgUrl,
width: size,
height: height ?? size,
package: package,
fit: fit ?? BoxFit.contain,
);
}
// 本地其他图
return Image.asset(
defImgUrl,
width: size,
height: height ?? size,
package: package,
fit: fit,
);
}
@override
Widget build(BuildContext context) {
Widget defView = _getDefView();
if (!supportSkinConfig) {
return defView;
}
return ValueListenableBuilder<String>(
valueListenable: imgNotifier,
builder: (BuildContext context, String value, Widget? widget) {
return value.isNotEmpty
? ImageView(
url: value,
width: size,
height: height ?? size,
fit: fit,
)
: defView;
},
);
}
}
接着继续适配颜色组件,由于原来大部分是通过Container
组件实现颜色的,这里还是参考这种方式,相当于在Container
基础上又包装了一层,外部只要多传入配置项和换肤开关即可。换肤颜色组件SkinColorContainer
具体如下:
class SkinColorContainer extends StatelessWidget {
final ValueNotifier<String> colorNotifier; // from SkinConfigMgr
final Color? color; // 原颜色要放在 color 属性而不是 decoration
final AlignmentGeometry? alignment;
final EdgeInsetsGeometry? padding;
final Decoration? decoration;
final Decoration? foregroundDecoration;
final double? width;
final double? height;
final BoxConstraints? constraints;
final EdgeInsetsGeometry? margin;
final Matrix4? transform;
final AlignmentGeometry? transformAlignment;
final Widget? child;
final Clip clipBehavior;
final bool supportSkinConfig;
SkinColorContainer(
this.colorNotifier,
this.color, {
this.alignment,
this.padding,
this.decoration,
this.foregroundDecoration,
this.width,
this.height,
this.constraints,
this.margin,
this.transform,
this.transformAlignment,
this.child,
this.clipBehavior = Clip.none,
this.supportSkinConfig = true,
});
static Color? getColor(String colorStr, Color? defColor,
{bool supportSkinConfig = true}) {
if (!supportSkinConfig ||colorStr.isEmpty) {
return defColor;
}
try {
colorStr = colorStr.toLowerCase().replaceAll("#", "");
if (colorStr.length == 6) {
colorStr = "ff" + colorStr;
}
return Color(int.parse(colorStr, radix: 16));
} catch (e) {
return defColor;
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<String>(
valueListenable: colorNotifier,
builder: (BuildContext context, String value, Widget? widget) {
return Container(
alignment: alignment,
padding: padding,
color: decoration != null || foregroundDecoration != null
? null
: getColor(value, color, supportSkinConfig: supportSkinConfig),
// decoration 和 color 不能同时设置,优先用 decoration
decoration: decoration,
foregroundDecoration: foregroundDecoration,
width: width,
height: height,
constraints: constraints,
margin: margin,
transform: transform,
transformAlignment: transformAlignment,
child: child,
clipBehavior: clipBehavior,
);
},
);
}
}
可以看到实际替换代码就非常简单了:
总结
对于不同的换肤需求提供不同的方案,在后面扩展时也能提供更多的选择。