聊实用功能设计系列之 信息流卡片的消费与曝光

这篇文章是《聊实用功能设计系列》的第一期,聊一聊我们线上项目信息流卡片曝光/消费的设计思路。

系列会拆分多期讲解线上项目中重要功能的设计过程。侧重点在于让读者理解功能模块的设计意义及整体搭建思路,大部分细节实现各项目自行实现即可。

背景

市场上大部分 APP 都采用了信息流的设计形式来承载信息内容展示,不同特征产品所设计的信息流形式不尽相同。

比如图片瀑布流,商品列表流或资讯Feed流等。

file

(淘宝商品流)

file

(懂车帝视频流)

尽管展示方式不同,但内容在列表中的流向是一致的,也都以列表滑动的交互来曝光更多内容。

而列表内容的展示效果可通过统计列表项的曝光次数/点击次数/曝光时刻/滑动时刻等数据进行多维度计算衡量。

如常见的点击率消费时长

点击率=点击次数/曝光次数,点击率越高则表示用户对内容进一步了解的意向越高。

消费时长=t(滑动时刻)-t(上一次曝光时刻),消费时长越长则表示用户对内容兴趣越高。

当然,不同列表内容的计算规则有所差异,考察内容展示效果的侧重点也不应相同。

如资讯类 Feed 流中内容项更多是以图文混排的展示形式来吸引用户进行点击消费,更关注内容的点击率。

而对于视频流内短视频快消类内容项,一般支持在流上直接播放,更关注内容的观看消费时长。

所以信息流列表项的曝光信息/消费数据对于衡量流投放效果至关重要,那么如何获取这些信息呢?

设计思路

以我们线上项目为例子。应用内信息流页面非常多。

有类微博的动态卡片流。

类资讯类卡片流。

双列视频流。

上述仅是 3 个常见场景,如果针对所有场景列表做设计,显然可维护性及扩展性非常差,所以必须考虑做成通用性设计。

我们约定几个高频词汇含义,后续描述都采用其代替表述。

  • 卡片,任意一个信息流列表项。
  • 曝光,卡片的曝光行为。
  • 消费,卡片的消费行为。
  • 有效面积,卡片暴露在界面的可见面积。
  • 有效面积率,卡片有效面积与卡片视图总面积的比率,一般可选 1/2 ~ 4/5
  • 有效卡片,卡片的有效面积率高于某个阈值时该卡片视为有效卡片,常见为 2/3,3/5。
  • 最小有效消费时长,当有效卡片的消费时长小于该时长时则认为用户并无消费行为,一般可选再 100~200 毫秒。

有效面积是用于计算卡片在界面的可视大小,当某个卡片可视区域太小了,哪怕是长期停留,我们都认为用户对其内容并无感知。

有效卡片则是用户对卡片内容有感知能力,这些卡片有机会得到曝光及消费。

有了上述约定,我们再定义一种通用的卡片曝光/消费规则:

  1. 当列表静止且用户与界面无接触时,列表内的有效卡片记一次曝光行为;
  2. 已静止列表重新开始滑动时,原静止列表内的有效卡片记一次消费行为。

则按照规则,卡片一次曝光消费的逻辑如下:

  1. 在列表静止且用户与界面无接触时,收集可见的所有卡片;
  2. 过滤有效面积率小于 3/5 的卡片得到有效卡片集进行缓存,得到缓存集 list[当前有效卡片]
  3. 针对有效卡片集进行曝光并记录曝光时刻点 t[exposure],实际上这个时间也是开始消费的时间 t[startConsume]
  4. 当列表开始滑动时,获取当前时间戳减去上一次曝光时间得到该次消费时长,若消费时长不短于 100毫秒,则list[当前有效卡片] 进行消费记录。
  5. 扩增强扩展。预留上述流程支持关键参数:比如是否动态调整有效面积率最小有效消费时长等。

另外,还可控制是否同一个界面内同一个卡片是否允许多次曝光,或控制卡片曝光的间隙等,按需实现即可。

具体实现

定义一个接口 CardLogFeed 描述卡片。

public interface CardLogFeed {

    //业务定义的卡片信息类,自定扩展修改
    @Nullable
    CardLogInfo buildCardLogInfo();
    
    //触发曝光回调
    void logExposure();
    
    //触发消费回调
    void logConsume(long time);
    
    //是否是有效卡片
    boolean isVaildLogUnit();
}

每一个卡片曝光/消费的回调行为不尽相同,开放给业务方实现。

CardLogInfo 类记录每个卡片应上报的日志信息,一般情况下曝光/消费都需要上报这些信息。

isVaildLogUnit 方法要求业务方告知当前卡片是否为有效卡片。该方法实现了设计思路章节涉及的有效卡片的判定逻辑。

另外,由于不同卡片样式上可能存在差异,如长方形,正方形,圆形,甚至不规则图形,实现思路并不局限在比较可视面积占比上,业务实现方自由选择。

定义一个类CradLogScrollLisener类,继承 RecyclerView.OnScrollListener 并实现列表相关的逻辑。

首先,重载 onScrollStateChanged 方法获取列表状态并根据不同状态进行处理。

//记录当前列表状态
private int currentNewState = SCROLL_STATE_IDLE;

@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
    super.onScrollStateChanged(recyclerView, newState);
    switch (newState) {
        //尝试曝光
        case SCROLL_STATE_IDLE: {
            exposureCardLog();
            break;
        }
        //尝试消费
        case SCROLL_STATE_DRAGGING: {
            if (currentNewState != SCROLL_STATE_SETTLING) {
                consumeCardLog(startConsumeTime);
            }
            break;
        }
        default:
            break;
    }
    currentNewState = newState;
}

通过捕获SCROLL_STATE_IDLE状态尝试曝光,exposureCardLog 方法内实现有效卡片的收集流程及曝光,下面为核心逻辑(只列核心逻辑)。

private void exposureCardLog() {
    //记录曝光时刻
    startConsumeTime = System.nanoTime();
    
    //收集有效卡片
    currentValidVisibleFeeds.currentValidVisibleFeeds();
    if (currentValidVisibleFeeds.isEmpty()) {
        return;
    }
    
    //上传曝光数据
    for (CardLogFeed logFeed : currentValidVisibleFeeds) {
        logFeed.logExposure();
    }
}

最后一步把收集到的 CardLogFeed 对象(有效卡片)进行曝光,而这些收集的逻辑在 currentValidVisibleFeeds方法内实现。

public List<CardLogFeed> getCurrentValidVisibleFeeds() {
    List<CardLogFeed> currentValidVisibleFeeds = new ArrayList<>();
    //获取layoutManager
    RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
    
    //针对线性排版
    if (layoutManager instanceof LinearLayoutManager) {
        int firstVisibleIndex = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition();
        int lastVisibleIndex = ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition();
        for (int i = firstVisibleIndex; i <= lastVisibleIndex; i++) {
            RecyclerView.ViewHolder viewHolder = mRecyclerView.findViewHolderForAdapterPosition(i);
            if (viewHolder instanceof CardLogFeed && ((CardLogFeed) viewHolder).isVailLogUnit()) {
                currentValidVisibleFeeds.add((CardLogFeed) viewHolder);
            }
        }
    }
    
    //针对瀑布流排版
    if (layoutManager instanceof StaggeredGridLayoutManager) {
        //...
    }
    
    //其他排版
    return currentValidVisibleFeeds;
}

以线性排版为例子,获取界面所有可见的 Holder 对象并判断每一个对象是否为 CardLogFeed 类型且满足有效卡片的判定条件,最终得到有效卡片集。

说完曝光,回到列表滑动时触发的 consumeCardLog 消费方法。

//1毫秒=1000000纳秒
private long DURATION_STEP = 1000000;

//定义最小有效消费时长 100 ms
private long MIN_CONSUME_DURATION = 100;

private void consumeCardLog(long startConsumeTime) {
    //获取时间间隔
    long consumeTime = (System.nanoTime() - startConsumeTime)/DURATION_STEP;
    if (consumeTime < MIN_CONSUME_DURATION) {
        return;
    }
    if (currentValidVisibleFeeds.isEmpty()) {
        return;
    }
    
    //卡片消费
    for (CardLogFeed logFeed : currentValidVisibleFeeds) {
        logFeed.logConsume(consumeTime);
    }
}

列表开始滑动时,就拿上一次曝光缓存的有效卡片做一次消费记录。

至此,完成一次完整的曝光/消费逻辑实现,核心流程不变,内部细节可自由发挥调整。

最后做个演示,当有效卡片曝光时设置成灰色,消费之后恢复原状。

2021-01-06 10_14_49.gif

后话

信息流卡片曝光/消费数据反馈对应用内容的投放策略有很大的指导意义。这篇文章仅作为探讨整体的程序设计思路,让更多开发者在未来遇到相似的场景时可参考比较。

如果有更好的思路或意见建议,欢迎讨论。

就开发者而言,有时候接到一个需求看似简单的需求,实际上在开发过程中才遇到各种妖魔鬼怪,养成良好的设计模式对每一个程序开发者都极其重要。

记得有一次,策划让我们做信息流的视频卡片的自动播放,说 “列表停下来后满足自动播放条件的视频卡片就播放,这应该挺简单吧?”。

我:“....”

那系列下期就聊聊信息流常见下,富媒体项的选择思路,包括 Gif 播放/视频播放常见。

欢迎关注追更。

欢迎关注 「Android之禅」公众号,和你分享有价值有思考的技术文章。
可添加微信 「Ming_Lyan」备注 “进群” 加入技术交流群,讨论技术问题严禁一切广告灌水。
如有 Android 领域有遇到技术难题亦或对未来职业规划有疑惑,一起讨论交流。
欢迎来扰。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343

推荐阅读更多精彩内容