这是我们APP原来的首页,整体的样式还是比较朴素的,特别在搞活动的时候,运营只能修改Banner、导航和商品推荐区的头图,来营造氛围,颇为令人诟病
为此,我们参考京东和淘宝的APP,将APP首页进行了重新设计,将其分成为这几个部分
- 背景
- 顶部搜索栏
- Banner区
- 资源位
- 导航区
- 营销活动区
- 商品推荐区
- 底部Tab按钮区
资源位可以横向切分成任意块,每一块拥有单独的点击事件。我们计划让运营可以在后台对APP首页的这八个部分进行前景图或背景图的配置,以此满足营造氛围感的需求。
这是我们预期的样子
确实好看很多吧!
为了支持这种设计,我们需要一个数据结构来存储配置
@Data
@EqualsAndHashCode(callSuper = true)
public class UiConfigurationDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 配置名称
*/
@ApiModelProperty("配置名称")
private String name;
/**
* 平台
* @see PlatformEnum
*/
@ApiModelProperty("平台")
private Integer platform;
/**
* 屏幕类型
* @see ScreenTypeEnum
*/
@ApiModelProperty("屏幕类型")
private Integer screenType;
/**
* 配置类型
*/
@EnumField(ConfigurationTypeEnum.class)
@ApiModelProperty("配置类型")
private Integer type;
/**
* 是否启用
*/
@ApiModelProperty("是否启用")
private Boolean useable;
/**
* 生效时间段 - 开始
*/
@ApiModelProperty("生效时间段 - 开始")
private Long startTime;
/**
* 生效时间段 - 结束
*/
@ApiModelProperty("生效时间段 - 结束")
private Long endTime;
/**
* 内部模块
*/
@ApiModelProperty("内部模块")
private List<UiModuleDTO> modules;
}
@Data
@NoArgsConstructor
public class UiModuleDTO implements Serializable, Redirect {
private static final long serialVersionUID = 1L;
/**
* 模块ID
*/
@ApiModelProperty("模块ID")
private Long id;
/**
* 模块名称
*/
@ApiModelProperty("模块名称")
private String name;
/**
* 前景图ID
*/
@ApiModelProperty("前景图ID")
private Long foreground;
/**
* 前景图URL
*/
@ApiModelProperty("前景图URL")
@JSONField(serialize = false)
private String foregroundUrl;
/**
* 背景图ID
*/
@ApiModelProperty("背景图ID")
private Long background;
/**
* 背景图URL
*/
@ApiModelProperty("背景图URL")
@JSONField(serialize = false)
private String backgroundUrl;
@ApiModelProperty("是否可见")
private Boolean visible;
/**
* 跳转类型
*/
@EnumField(YanXuanEnum.RedirectTypeEnum.class)
@ApiModelProperty("跳转类型")
private Integer redirectType;
/**
* 跳转目标
*/
@ApiModelProperty("跳转目标")
private String redirectTarget;
/**
* UI模块
*/
@ApiModelProperty("内部模块")
private List<UiModuleDTO> modules;
public UiModuleDTO(Long id, String name) {
this.id = id;
this.name = name;
this.foreground = 0L;
this.background = 0L;
this.visible = Boolean.FALSE;
this.redirectType = 0;
this.redirectTarget = "";
this.modules = new ArrayList<>();
}
}
考虑到IOS和Android之间UI规划和屏幕样式可能会有差别,我们在配置上标记了适用的系统和屏幕类型,而真正存储配置内容的UiModuleDTO类则采用了自由嵌套的结构。
很明显,UI配置是一个读多写少的场景,我们希望它可以提供尽可能好的并发读性能,所以我们打算将其存储在Redis中。另外考虑到运营会同时创建多个UI配置,有定期生效的需求,我们设计了这么几个Key
/**
* [id]指代具体的id值
* [uiConfiguration]指代uiConfiguration实例对象
* [platform]指标适用平台
* [screenType]指代适用屏幕样式
* [startTime]指代uiConfiguration实例对象
* [endTime]指代uiConfiguration实例对象
*/
// key1:用String存储ID对应的UI配置实例对象
SET uiConfiguration:id:[id] [uiConfiguration]
// key2:用zset存储ID对应的开始生效时间
ZADD uiConfiguration:[platform]:[screenType]:startTime [id] [startTime]
// key3:用zset存储ID对应的结束生效时间
ZADD uiConfiguration:[platform]:[screenType]:endTime [id] [endTime]
APP请求UI配置内容时,会携带platform和screenType参数,后端根据这两个参数可以构造出key2和key3。然后使用ZRANGEBYSCORE
命令在key2中找出结束生效时间小于当前时间的失效ID集合,如果失效ID集合不为空,则把这些ID在key1、key2和key3中对应的值删除。
再使用ZRANGEBYSCORE
命令在key3中找出开始生效时间小于当前时间的ID集合,若ID集合为空,说明没有可用的配置,反之则取开始时间最早的一个ID,然后在key1中找出具体的配置内容返回给APP
这里面有一个zset的小细节,当score值相同时,zset会根据value进行排序,这里使用的是字典序。我们预期的是当开始生效时间相同时,应该取ID小的那条记录。但如果ID是99、100之类的数字时,你会发现实际取到的是100,因为根据字典序的规则100要比99小。为了规避这个问题,我们在实际开发中,将ID处理成了统一长度的19位字符串(在数字ID前面填充0)
有同学可能会觉得每次查询的时候都去删除缓存很不合理,但大多数时候查询出的失效ID集合都是空哈,所以实际去删除缓存的次数不会多。
代码参考
@Slf4j
@Component
public class UiConfigurationDubboServiceImpl implements UiConfigurationDubboService {
private final static List<UiModuleDTO> EMPTY_MODULES;
private final UiConfigurationCache cache;
static {
EMPTY_MODULES = new ArrayList<>();
EMPTY_MODULES.add(new UiModuleDTO(1L, "搜索栏顶部"));
EMPTY_MODULES.add(new UiModuleDTO(2L, "首页底图"));
EMPTY_MODULES.add(new UiModuleDTO(3L, "资源位"));
EMPTY_MODULES.add(new UiModuleDTO(4L, "首页导航区"));
EMPTY_MODULES.add(new UiModuleDTO(5L, "固定活动区1"));
EMPTY_MODULES.add(new UiModuleDTO(6L, "固定活动区2"));
}
/*
* 此处省略N行代码
*/
/**
* UI配置缓存类
*/
private static class UiConfigurationCache {
private final static String START_TIME_SET_KEY = "uiConfiguration:startTime:";
private final static String END_TIME_SET_KEY = "uiConfiguration:endTime:";
private final static String CONFIGURATION_KEY_PREFIX = "uiConfiguration:id:";
private final RedisTemplate<String, Object> redisTemplate;
public UiConfigurationCache(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
private void addCache(UiConfigurationDTO config) {
String prefix = getPrefix(config);
Long id = config.getId();
String strId = String.format("%019d", id);
if (ConfigurationTypeEnum.TEMPORARY.getCode().equals(config.getType())) {
redisTemplate.opsForZSet().add(START_TIME_SET_KEY + prefix, strId, config.getStartTime());
redisTemplate.opsForZSet().add(END_TIME_SET_KEY + prefix, strId, config.getEndTime());
redisTemplate.opsForValue().set(CONFIGURATION_KEY_PREFIX + prefix + ":" + id, config);
} else {
redisTemplate.opsForValue().set(CONFIGURATION_KEY_PREFIX + prefix + ":default", config);
}
}
private String getPrefix(UiConfigurationDTO config) {
String prefix = "";
if (Objects.equals(config.getPlatform(),PlatformEnum.ANDROID.getCode())){
prefix = prefix + config.getPlatform();
} else {
prefix = prefix + config.getPlatform() +":"+ config.getScreenType();
}
return prefix;
}
private void delCache(UiConfigurationDTO config) {
String prefix = getPrefix(config);
Long id = config.getId();
String strId = String.format("%019d", id);
if (ConfigurationTypeEnum.TEMPORARY.getCode().equals(config.getType())) {
redisTemplate.opsForZSet().remove(START_TIME_SET_KEY+ prefix, strId);
redisTemplate.opsForZSet().remove(END_TIME_SET_KEY + prefix, strId);
redisTemplate.delete(CONFIGURATION_KEY_PREFIX + prefix + ":" + id);
} else {
redisTemplate.delete(CONFIGURATION_KEY_PREFIX + prefix + ":default");
}
}
private UiConfigurationDTO getConfiguration(Integer platform) {
long nowTime = System.currentTimeMillis();
// 获取已失效的配置
Set<Object> disableConfigurationIds = redisTemplate.opsForZSet().rangeByScore(END_TIME_SET_KEY + platform, 0, nowTime - 1);
if (CollectionUtil.isNotEmpty(disableConfigurationIds)) {
// 移除失效的UI配置缓存
disableConfigurationIds.forEach(id -> {
String strId = String.format("%019d", id);
redisTemplate.opsForZSet().remove(START_TIME_SET_KEY + platform, strId);
redisTemplate.opsForZSet().remove(END_TIME_SET_KEY + platform, strId);
redisTemplate.delete(CONFIGURATION_KEY_PREFIX + platform + ":" + id);
});
}
Set<Object> configIds = redisTemplate.opsForZSet().rangeByScore(START_TIME_SET_KEY + platform, 0, nowTime);
if (CollectionUtil.isEmpty(configIds)) {
return (UiConfigurationDTO) redisTemplate.opsForValue().get(CONFIGURATION_KEY_PREFIX + platform + ":default");
} else {
Optional<Object> optional = configIds.stream().findFirst();
String strId = (String) optional.orElseThrow(() -> new RuntimeException("未知异常"));
long id = Long.parseLong(strId);
return (UiConfigurationDTO) redisTemplate.opsForValue().get(CONFIGURATION_KEY_PREFIX + platform + ":" + id);
}
}
private UiConfigurationDTO getConfiguration(Integer platform, Integer screenType) {
long nowTime = System.currentTimeMillis();
UiConfigurationDTO config = new UiConfigurationDTO();
config.setPlatform(platform);
config.setScreenType(screenType);
String prefix = getPrefix(config);
// 获取已失效的配置
Set<Object> disableConfigurationIds = redisTemplate.opsForZSet().rangeByScore(END_TIME_SET_KEY + prefix, 0, nowTime - 1);
if (CollectionUtil.isNotEmpty(disableConfigurationIds)) {
// 移除失效的UI配置缓存
disableConfigurationIds.forEach(strId -> {
long id = Long.parseLong((String) strId);
redisTemplate.opsForZSet().remove(START_TIME_SET_KEY + prefix, strId);
redisTemplate.opsForZSet().remove(END_TIME_SET_KEY + prefix, strId);
redisTemplate.delete(CONFIGURATION_KEY_PREFIX + prefix + ":" + id);
});
}
Set<Object> configIds = redisTemplate.opsForZSet().rangeByScore(START_TIME_SET_KEY + prefix, 0, nowTime);
if (CollectionUtil.isEmpty(configIds)) {
return (UiConfigurationDTO) redisTemplate.opsForValue().get(CONFIGURATION_KEY_PREFIX + prefix + ":default");
} else {
Optional<Object> optional = configIds.stream().findFirst();
String strId = (String) optional.orElseThrow(() -> new RuntimeException("未知异常"));
long id = Long.parseLong(strId);
return (UiConfigurationDTO) redisTemplate.opsForValue().get(CONFIGURATION_KEY_PREFIX + prefix + ":" + id);
}
}
}
}