APP商品SKU选择实践

566 阅读16分钟

本文涉及关键字:Android,商品SKU,无向图,算法,购物车,属性弹窗

契机

用户反馈当商品属性在某一个维度只有一个属性,且有库存的情况下能不能自动选中,毕竟不需要人为做决策,针对此类商品便可以直接下单。

这是一个体验问题,是每一个商品APP都要考虑的问题,顺道我们就扒一扒行业内top APP都怎么做的吧

分别分析了网易严选,天猫,京东APP在商品SKU选择上的交互效果,抛开审美只看逻辑会发现是一样的,从用户引导与用户习惯上看这都是行业内较为统一的实现方式,于是我们进行了借鉴。(针对有/无库存都进行了展示区分,且无库存与有库存均可点击,并自动切换上下游关联的属性库存状态)

对比发现我们老版实现针对无库存是不可点击的,用户想要切换至无库存的SKU时需要先取消上级属性才行。在操作上总是会多一步取消选择操作。

再结合历史代码情况分析,属性选择作为一个商品类APP,几乎是每一个商品页面都在接入,一个弹窗随着几年的迭代堆积已经不堪重负。相比自动选中,更大的优化空间是整个属性选择弹窗的重构。

正文

示范Demo例图:

1.UI与数据的映射分析

为了简化数据样本,下文以字母替代真实场景中的属性

  • 按钮的基础效果

作为UI交互,通常我们从外向里分析,先拆分出UI细节。本文案例中,可以将按钮效果拆分为以下效果

从左往右依次是:无库存,无库存被选中,有库存,有库存被选中

  • 属性清单数据
[
    {
      "title": "A",
      "childList": [
        {"text": "A1","id": "1"},
        {"text": "A2","id": "2"}
      ]
    }
 ......
  ]

  • 组合清单数据
[
    {
      "ids": "1,3,5,7",
      "texts": "A1,B2,C1,D1",
      "num": 99
    }
 ......
 ]

我们以4个属性维度,每个属性维度2个属性为例,原始的UI状态则如下图:

2.来个栗子

假设有三个可用组合如下:

[
    {
      "ids": "1,4,5,7",
      "texts": "A1,B2,C1,D1",
      "num": 99
    },
    {
      "ids": "2,4,6,8",
      "texts": "A2,B2,C2,D2",
      "num": 100
    },
    {
      "ids": "2,3,5,7",
      "texts": "A2,B1,C1,D1",
      "num": 101
    }
  ]

使用UI效果显示则为下图的样子:

这里需要明确一个展示库存样式的逻辑:对于每个属性,当用户可能点这个属性时,如果任意sku里包含这个属性组合,那么这个属性是有有库存状态,否则就是无库存状态

举例:假设已经选中了B1、C1

要确定A1有没有库存,那么就去sku里找有没有“A1,B1,C1,*”这类sku。只要找到一个,那么A1就是有库存状态;没找到,A1就是无库存状态。其他A2、D1、D2都是同理

要确定B2有没有库存,那么就去sku里找有没有“,B2,C1,”这类sku。C2也是同理,B1、C1自己也同理

业务逻辑已明确,接下来就是分析实现方式了。

我们分析了解到3种解法分别是暴力查找、优化版查找、无向图,下文将会进行递进式表述。

在本文的案例中对数据体量是十分清楚的,只围绕实际案例针对性进行分析,所以在下文中不会用大O表示法去分析复杂度等性能情况。这并不是一篇纯粹的从性能角度做算法优化的案例介绍。

2.1 暴力查找

基于上面“展示库存样式的逻辑”,可以直接从第一个属性遍历到最后一个属性依次匹配sku,最终就能得到所有的属性状态。

逻辑:

1.获取选中数据
遍历属性集合{
    获取用户已选择数据并存起来(如保存到map:key=line(选中的行号),value=selectedId(选中的属性id))
}
2.获取可选的属性id
遍历所有属性{
    如果当前属性和用户已选择数据不在一行{
        当前属性和用户已选择数据合并一起得到新组合
    }否则同行{
        当前属性替换掉用户已选择数据得到新组合
    }
    遍历所有sku{
        如果当前sku包含新组合用set集合保存当前属性
        跳出并继续下一个属性循环
    }
}
3.遍历修改有无库存
遍历保存的set集合{
    遍历attr属性集合{
        将包含的id都设置为可点击不包含的都不可点击
    }
}
4.更新UI
基于数据源驱动,直接刷新ui重新取数据即可

2.2 优化版查找

上面虽然解决了问题,但仅主逻辑就嵌套遍历了3层(遍历属性>>遍历组合>>遍历判断属性是否都相等)。也就是说如果一个4*4的属性,不考虑优化的情况下仅主逻辑就需要遍历16(属性数)*256(sku数)*4(相等判断)=16384次。

上述直接穷举方式是先遍历属性(以每个属性为主)然后决定属性的库存状态,再仔细看看下图,换个思路思考,如果先遍历sku(以单个sku为主):

  1. 只要包含B1、C1就是有库存状态,一次性就能得到4个有库存的属性。
  1. 我们只需要找有库存的属性,没找到的最后都是无库存。

以sku为维度分析当选中B1、C1时,下一次可能的结果:

  • 可能取消B1或C1

    • 这里主要展示用户下一次的行为,取消只需重新走逻辑,和其他按钮是否有库存无关
  • 可能新增A1、A2、D1、D2其中一个

    • 这时对于包含B1、C1的“A2,B1,C1,D1”这个sku来讲,A2、B1、C1、D1是有库存的(因为它们都在该sku里,点这些按钮一定有库存)
  • 可能切换B1→B2、C1→C2

    • 对于“A1,B2,C1,D1”这个sku,如果切换到B2是可以直接匹配到这个sku的。这个sku的A1、C1、D1是不是有库存呢?对于“A1,B2,C1,D1”依然是无库存的了,因为就算切换到A1(“A1,B1,C1,”)、C1(“,B1,C1,”)、D1(“,B1,C1,D1”)也不会和这个sku直接匹配(图上C1、D1有库存是因为上面一个sku已经判断出有库存了)

所以得到一个结论:

当选中了某个属性时,遍历sku

  1. 如果sku完全包含已选中的属性,则该sku所有的属性均有库存
  1. 如果sku和选中的属性只有某一行不一样,则不一样的这个属性有库存

总结

  • 首先如果没有选择(特例情况)

    • 则sku所有的属性均有库存
  • 如果选了n个属性

    • 如果sku完全包含已选中的属性,则该sku所有的属性均有库存(新增属性)
    • 如果sku和选中的属性只有某一行不一样,则不一样的这个属性有库存(切换属性)

代码步骤:

1.初始状态遍历sku保存其中的每一个属性,被记录的均为有库存
2.如果选择了n个属性,遍历所有sku
    2.1其中所有包含已选组合的均保存下来(新增属性代码)
    2.2再根据已选信息,改掉其中一个属性并遍历匹配包含的组合,保存改掉的那个属性(切换属性代码)
3.遍历所有属性,将保存的id依次设置为有库存,未保存的均无库存

2.3 再次优化

如果按上面代码步骤会发现依然需要嵌套遍历3层(遍历sku组合>>遍历完全 包含或改掉一个>>遍历是否相等),实际遍历次数和直接穷举并没有太大的差别。仔细想想某个sku组合(A1,B2,C1,D1)和选中的属性(,B2,C2,)的“完全包含”和“改掉一个”代码逻辑看起来是不是很像?第一次判断的没选是不是和“完全包含”是不是一个逻辑?

这其实就是遍历sku组合找sku的id和选中属性的id不相等的个数:

  • 0个(一个不差)就是完全匹配:该sku均有库存
  • 1个(错位一个)就是某一行不匹配:该行的属性id有库存
  • 2个(多个错位)及以上就直接无法匹配:该sku无法匹配,尝试其它sku

得到最终代码步骤:

1.获取选中数据
遍历属性集合{
    获取用户已选择数据并存起来(如保存到map:key=line(选中的行号),value=selectedId(选中的属性id))
}
2.获取可选的属性id
遍历sku组合集合{
    遍历用户已选择数据集合{
        选择数据和sku匹配并记录匹配失败个数和失败的位置
    }
    判断{
        如果失败个数=0{
            该组合全部的id都可点击(可以先用set集合全部保存)
        }
        如果失败个数=1{
            该组合失败位置的id可点击(用set集合保存这一个)
        }
        超过1{
            无需保存
        }
    }
}
3.遍历修改有无库存
遍历保存的set集合{
    遍历attr属性集合{
        将包含的id都设置为可点击不包含的都不可点击
    }
}
4.更新UI
基于数据源驱动,直接刷新ui重新取数据即可

算法图文解释,共分4步,依次执行:

image.png

2.4 无向图方式

无向图wiki

看到一些分享无向图的文章,粗略实践似乎是非常不错的选择。但随着深入了解,结合社区的反馈与实践验证了无向图在处理此案例时存在明显的漏洞。

如下图,有3行属性,sku组合为:“A1,B1,C2”、“A1,B2,C1”、“A2,B1,C1”

用户在选择时没有顺序的,所以用无向图表示,无向图大概长这样:

接着需要邻接矩阵了,邻接矩阵就是把无向图的每一个顶点(初始起点)作为横纵坐标,坐标交叉的属性用“0/1”代表是否可达。

对A1这个顶点示例,按上面的sku组合,能到达的有如下:

继续按我们上面配的属性和sku画出的邻接矩阵如下:

  • 当用户选择了某一列,则该列的1就代表有库存
  • 当用户选择了某两列,则只需对这两列求交集(按位与)就行了,如下用户选择了“A1,B2”,则得到B1、C1可选(因为我们有“A1,B2,C1”和“A1,B1,C2”这个sku)

但是当选中了“A1,B1”时,按矩阵的结果C1也是1(可选),但实际上我们根本没有“A1,B1,C1”这个sku,当选中它后却发现它又变成了无库存。

为什么会出现这种结果呢,我们先回顾一下sku的示例图

第二步就是要把它变成了无向图,既然无向势必要统一红、绿、蓝,把颜色去掉后如下:

到此图应该已经找出了问题的原因:无向图会抹去sku的链式关系,变成无向图后,没有了链的关联,那么“A1,B1,C1”这个sku在无向图里理所当然是可以的了。

至此已经破案了。

“既然无向图不行,那有向图可以吗?”

答案还是比较失望,因为有向图只能决定上下关系,并不能决定整条链的关系

2.5 无向图的优化方案探索

“难道无向图真的无法实现了吗?”

经过上面分析得出sku必须保留链式关系,而无向图却丢失了这层关系,把它补上是不是就可以实现了呢?

  • 实现方式一:根据无向图生成邻接矩阵+数组的关系图

要查缺补漏,这让我想起了一个类——LinkedHashMap

做法很简单:它将每个数据的上下关系都保存了下来,遍历时按上下关系遍历即可,在邻接矩阵里每一个元素加上对应sku组合信息。

步骤如下:

  1. 因为需求要支持无库存商品的选择,所以原始矩阵的A1-A1、A2-A2自身无法确定有无库存,我们暂定它也算作一个连接点,如果有则为1,完全都没有则为0

  2. 为每个连接点保存当前匹配的sku信息。如A1链接到A2,那么他只有“A2,B1,C1”这个sku

  3. 和之前无向图逻辑一致,先求交集得到出现1的继续对sku进行对比,如果有sku相同则该项为有库存,没有一个sku相同则该项为无库存。如A1链接到B1,得到数据“1,1,1,1,1,1”,然后遍历所有包含1的值,得到A1、A2、B1、B2、C2是有相同的sku,而C1则没有相同,所以最终得到的结果是“1,1,1,1,0,1”,然后遍历设置属性就行了

邻接矩阵+数组示意图:

  • 实现方式二:直接根据行数增加邻接矩阵的维度

根据实现方式一不难发现,二维邻接矩阵实际上就是减少了前两次选择的遍历成本,如果是66、77等更多的属性个数,矩阵的实时计算并不那么理想。

“难道直接使用邻接矩阵真的没有办法保存链式关系了吗?”

此时有个大胆的想法:只要根据行数增加邻接矩阵维度,让邻接矩阵变成三维、四维、五维...。也就是如果有n行,那就有个对应的n维邻接矩阵,而n维邻接矩阵已经完全保存了每一个组合信息和每一种选中后的结果,当有数据选中时n维邻接矩阵上直接就能找到,那么就不需要继续遍历里面的数据了

实际上这种做法已经把每一种可能的选择都全部记录进去了,实际占用的内存也是根据行列数指数级上升

  • 一维邻接矩阵示意:

  • 二维邻接矩阵示意:

  • 三维邻接矩阵就是立方体里面根据各属性分别对应0和1
  • 更高维度此时已经无法靠想象来形容了,但代码其实很容易实现的,比如333的三维矩阵:[[[1,0,0],[1,0,0]],[[1,0,0],[1,0,0]]]

暴力穷举和无向图优化版的对比

直接穷举优化版穷举二维邻接矩阵多维邻接矩阵
遍历次数很高(属性数sku数行数)高(sku数*选中数)中等((行数-2)列数行数*列数)可以忽略(map直接取值)
预占用空间中等指数级增长
实现方式遍历所有属性和所有sku遍历所有sku和选中数据穷举部分属性和所有sku,并遍历穷举所有可能的结果
代码难度简单简单复杂特别复杂
优点当属性数固定,遍历数跟随sku数相关类似f(x)=-x^2的曲线样式,所以sku数很少或者很多的时候占优综合性的遍历次数,比较稳定当属性数固定,遍历数跟随sku数相关类似f(x)=1/x(x>0)的曲线样式,所以sku数很少的的时候占优占用空间数跟随行数相关类似f(x)=x^x增长,所以行数很少的时候占优
举例说明

假设我有个4*4数据,属性数共计16个,sku数共计128个(一半有效sku),属性都已经各选了一个

  • 直接穷举方式

    • 无需提前占用内存
    • 循环次数:第一步获取选中的数据16次,第二步获取可选的属性id 16*(约128/2)*4≈4096
  • 暴力穷举方式

    • 无需提前占用内存
    • 循环次数:根据升级版的遍历情况,第一步获取选中的数据16次,第二步获取可选的属性id128*4次,第三步修改可选状态循环8704次
  • 二维邻接矩阵方式

    • 需要提前申请1616((4-2)*4)=2048条数据大小的空间
    • 需提前遍历初始化一下,约2048次
    • 循环次数:每次执行循环200次左右
  • 多维邻接矩阵方式

    • 需要提前申请161616*16=65536条数据大小的空间
    • 需提前遍历初始化一下,约65536次
    • 循环次数:每次执行循环32次(固定值)

以上便是本文的全部内容,没有最优解,合适才最重要。

题外话

终于重构到了商品属性选择这块5年陈年老代码。

软件迭代的生命周期特性决定了实现软件的代码一定会过时,尤其是在应用型软件中过时的周期往往较短,短则半年,长也就3-5年。这也导致人人都可以吐槽过时的代码,因为人总是新的,技术总是新的。

我们会经常听到吐槽历史代码“屎”,有时候会变成段子,有时候也作为迭代困难的主要甩锅目标,甚至影响产品设计与业务发展。想想离职多年的“前同事”时不时会打两个喷嚏,其实蛮冤枉的。

在团队里,我们不提倡吐槽历史代码,提倡把问题分析透彻,而不是分析代码作者的水平。穷尽维度的去收集解决方案并环比做利弊分析,再结合目前的团队资源,公司业务增长速度等,制定好迭代的方向,确认成本。时机成熟,我们就可以自信的下手去干,心里有底的去干。

经过1年多的努力,我们在IOS端重构了接近60%的模块,Android也接近40%。每一次重构往往需要产品,测试,甚至是运营协助,用户协助,在这个过程中也是对部分项目主力开发的考验。 如果只是单纯的看某段代码不顺眼,就想把他改掉,这本质上没有错,但是「能跑就行」这四个字是有正向现实意义的。

一个小小的商品属性弹窗,能延伸出这么多的信息,我们自己也没想到。甚至最后两个方案显得那么的不成熟,但是在探索的过程中取得的收获比结果本身要重要的多。

作为技术从业者,在钻研技术上多践行「第一性原理」,多探索本质。

原作者:小N 校对:小Z

END

原稿:WN 整理:ZSW


demo:github.com/Western-par…