diff --git a/archives/2025/02/index.html b/archives/2025/02/index.html index 4763fe6..786e669 100644 --- a/archives/2025/02/index.html +++ b/archives/2025/02/index.html @@ -132,7 +132,7 @@

Archives

- +
diff --git a/archives/2025/index.html b/archives/2025/index.html index 59dd71d..8721591 100644 --- a/archives/2025/index.html +++ b/archives/2025/index.html @@ -132,7 +132,7 @@

Archives

- +
diff --git a/archives/index.html b/archives/index.html index 058e09f..beffc7c 100644 --- a/archives/index.html +++ b/archives/index.html @@ -132,7 +132,7 @@

Archives

- +
diff --git a/atom.xml b/atom.xml index b5305d8..15cc2cc 100644 --- a/atom.xml +++ b/atom.xml @@ -6,7 +6,7 @@ - 2025-02-14T09:06:53.426Z + 2025-02-14T09:20:46.738Z https://blog.bingyan.net/ @@ -20,8 +20,8 @@ ARG分享 https://blog.bingyan.net/posts/47a7600d/ - 2025-02-14T09:06:00.000Z - 2025-02-14T09:06:53.426Z + 2025-02-14T09:20:00.000Z + 2025-02-14T09:20:46.738Z 2020 年 10 月,我刚刚加入冰岩,成为了冰岩前辈和烛芯团队共同完成的 ARG 作品《iKnowGhost》的菜鸟玩家。

在体会到这款游戏带来的独特体验之后,我们在 2022 年 4 月尝试设计了一款面向华科的 ARG《Ronin》。这款在毕业季发布的校园 ARG 吸引了 200+ 玩家的参与,最终在 14 小时内被完整通关。

我们对整个游戏设计的过程做了一些复盘,并从中总结出了一些经验和大家分享,希望可以对读者朋友之后的 ARG 或解谜游戏设计提供一些建议。

ARG 是什么

平行实境游戏(Alternate Reality Gaming)是一种以真实世界为平台,融合各种虚拟的游戏元素,玩家可以亲自参与到角色扮演中的多媒体互动游戏,在国内,有的译做侵入式虚拟现实互动游戏,另类实境游戏,候补现实游戏,替代现实游戏等。它是一种以真实世界为平台、融合了各种虚拟的游戏元素、玩家可以亲自参与到角色扮演中的多媒体互动游戏。

ARG 能将故事和游戏元素更紧密地结合在一起。通常,ARG 有一个强大而复杂的故事,但是故事被分解成小片段,嵌入到各种各样的媒体资产和其他形式的传播形式中。为了重组这些故事片段,玩家必须解决一系列的难题。这些难题通常会把玩家带到现实世界中去寻找线索或者参加与故事相关的活动。

设计思路

ARG 常用作电影、游戏等的宣发手段,更多地采用故事先行的方式,先根据主体内容来设计故事线,再在此基础上设计机制和关卡。我们的 ARG 没有营销目的,但在设计时也同样采用了故事先行的设计思路,主要为了我们自己能够理顺谜题和关卡的目的。当然,这样的设计思路可能让我们的想法一定程度上受到故事带来的限制。

整体看来,我们的设计思路大概是这样的:从故事出发,根据故事进程来设计对应的谜题,中间通过地点要素来实现线上线下结合和故事的推进,之后给每个关卡嵌套增加难度的外壳,最后将彩蛋置于游戏进程中的细节。

《Ronin》的剧本出发于毕业季前夕,大学生普遍存在的关于毕业、升学、找工作的焦虑。我们虚构了 Ronin(名字来源于“浪人,流浪武士”的英文解释)的人物形象,让他成为整个游戏的主人公和线索,带着玩家们探索故事和我们希望传达的价值观。

主人公 Ronin 在大四时发现自己的室友失踪,便发布了寻人启事,希望大家帮助寻找室友。随着寻找的深入,玩家逐渐发现 Ronin 的室友在毕业季陷入了多方压力的漩涡——来自导师对将游戏作为毕设的质疑、来自父母要求延毕考研的压力、来自找工作受挫的压力使他充满了迷茫。通过合作,玩家最终完成了找到室友的目标,而这背后隐藏着更大的真相:失踪的室友就是 Ronin 本人,而这场 ARG 才是 Ronin 的毕设,玩家通过参与 ARG 帮助 Ronin 顺利毕业的同时,让他更加清晰了做游戏的意义。

在初版的剧本设计完成后,我们在其中的一些节点设计了关卡和谜题,来分割几个故事阶段,通过内容平台和地点的切换来存放解谜线索和串联情节。

这些谜题有的来自与群主的互动,有的来自空间视频某帧,有的来自 BBHust 中 Ronin 发布的内容,或来自如公众号、GitHub、百度网盘、网站控制台、线下等各种场景。具体可以参考玩家在 QQ 群中最终完成的解谜文档:https://docs.qq.com/doc/DQUJZd2t2SnhwalVi?dver=

核心要素

故事

真实

故事是 ARG 的核心,ARG 的吸引力很大程度上来自“AR”(即“入侵现实”)的部分。和一般解谜游戏的在虚拟中建构世界观不同,ARG 本身就像一部改编自真实事件的小说,人物、情节和环境三个要素都取材自现实生活。ARG 中的游戏人物需要尽可能还原现实世界的“人”的形象,有着基于现实世界的目标和追求,而玩家要做的就是通过游戏参与,干预游戏人物在目标实现上的进程。

在《Ronin》的设计中,我们将游戏主人公 Ronin 设定为 H 大学游戏编程与设计专业的大四学生,有着自己的社交帐号、学号和生活场景,也有着自己的好友、导师等社交关系。Ronin 的性格取材于临近毕业的大四学生,焦虑、迷茫、需要肯定。在这样的人物设定下,我们为他编写的故事就更加具有真实性,其中发生的矛盾冲突也更能使玩家代入其中。

这样的设计存在的问题是容易让玩家猜到剧情的走向。这还需要我们在设计故事时再多强调一些戏剧性。

悬疑

大部分的解谜游戏都包含了一些悬疑氛围,用于增强玩家探索故事情节的好奇心,同时强化游戏带来的感官刺激。ARG 则借助它的真实性,可以更多地将悬疑的氛围创设于真实场景中。

《Ronin》的悬疑色彩起于室友的失踪,落脚于室友和 Ronin 是同一个人。游戏进程中的游戏 demo、视频等内容中也存在悬疑元素。AB 面人设对游戏策划来说也是一个大工程,两面人设之间有交叉和转换,且都承担了内容主体的责任,这非常考验策划的逻辑性。或许避免让同一组策划同时完成两面人设的设定,会更好地避免混淆的问题。

谜题和线索

谜题设计主要有两个思路,即线性的和平铺的。线性谜题能让玩家随着解谜不断解锁新线索,一步步深入故事;而平铺谜题能给玩家更丰富的开放世界的体验。

《Ronin》主要采用了线性谜题设计。我们将谜题收入一个网页(http://bysj.hust.online),一方面是为了让玩家有阶段性的参与感,不至于在完整解谜之前一团雾水,另一方面是为了将网页也作为一个信息载体,在控制台和解谜结束后的网页主体中也存放一些涉及故事和线索的信息。在解谜的过程中,我们努力让线索随解谜进程而出现,但一些突发状况的出现使得更多线索被提前解锁了,玩家拿到的可能是大于当前关卡解谜需要的线索。

线性有线性的思路,开放世界也有开放世界的玩法。线性意味着我们要主动控制线索的曝光,开放世界则更多地需要玩家组织思路来解谜。在实际操作的过程中,也可以将这两者结合(例如标签性的谜题提示来绑定线索和谜题。这是我们摸索出的能够尽可能兼顾线性的破局思路和开放世界的丰富体验的方案)。

地点要素

校园场景内的地点要素选择比较局限,一般以能承载信息、能串联故事情节为标准来选取地点。在设计地点时也可以包含合作的意义,让互联网上的玩家们跨越地域来开展更加丰富的合作(如锈湖白门就采用了这样的设计,可以参考 4.3)。

在我们的设定里,Ronin 是个热爱游戏、不爱交际的男生,平时的行踪也不容易被其他人得知。我们将 Ronin 定位于西区宿舍,在校园内的活动轨迹需要通过买水果捞、坐校车这样的行为串联起来。而西边生活区改建为他能够来到绝望坡提供了契机。

类似地,我们通过 Ronin 的 BBHust 社交帐号将线索留在冰岩作坊的门上。通过这样的方式,我们尝试让东边的同学也能参与到线下的互动中来。

设计难点

信息的价值

ARG 的故事需要有很多载体,如头像、图片、文字、视频、音频、文件等,这些内容可能出现在表层(如社交平台、视频片段等),也可能出现在深层(如网站控制台、文件属性等)。无论是游戏设计的过程还是玩家参与游戏的过程,都出现了大量信息。玩家获得的大量信息中,仅有部分是作为线索对实际解谜产生价值的,也有一部分是作为彩蛋用于映射 true ending。

如何让这些信息发挥最大的价值,避免无用的信息(例如《Ronin》策划过程中,微信公众号文章的内容仅使用了一次,且没有放置彩蛋),需要游戏策划多花心思将目前没有价值的内容绑定到故事线中,或剔除这些内容(同时不能因此过分影响内容丰富性)。当然,为了控制游戏进程,也可以设置一些迷惑玩家的信息,操作解题的难度。

游戏进度的控制

一款 ARG 的预计时间由几小时到一个月,游戏进度太快或太慢都会带来问题(对于包含营销目的的 ARG 更是如此)。为了保持玩家解谜的速度在可控范围内,我们需要一些额外的风险控制方案。

太慢

比较容易解决的情况,通过提示或调整线索开放的速度来帮助玩家解谜。

太快

通过线下点开放的时间和放出线索的时间来操作进度太快的情况。对于后者,我们需要在故事逻辑里讲清楚原因,否则会造成玩家被强行截断进度的反感。在不同玩家之间产生竞争,也可以很大程度上设置障碍。

竞争与合作

玩家之间的竞争能够很大程度上创造不确定性,从而提高玩家的动力,然而也会带来更加难以实现的大圆满以及进度控制上的问题——毕竟玩家的想法在设计时可以预估,但没法完全左右。反过来说,合作也会成为玩家间产生互动的形式,玩家社群内产生的互动可以提高玩家参与感,玩家在收集线索、互相帮助上产生的合作也能为游戏本身延申更多乐趣。

解谜的形式

我们在尝试设计这款 ARG 前,体验了一些市面上的 ARG。由于其题材的特殊性,大多解谜都涉及密码、网络、数字、音视频处理等知识。复盘过程中,我们发现了和《iKnowGhost》存在的类似问题,即文科生在解谜过程中的参与感不足。解谜的形式用过于硬核的方式呈现,很大程度上会限制参与者的发挥。

在体验了更多类型的解谜游戏之后,我们也找到了一些能够让更多不同学科视角的同学参与进来的方法(如文字、诗词、图形、音乐等)。设计出这样的谜题,当然也需要策划组本身有来自不同学科不同专业的同学参与。

过关和看剧情的冲突

支撑 ARG 进行需要充分的剧情,剧情可以用于承载线索或完善人物背景,也可以间接传达游戏背后的价值观。在实际上线运营后,我们发现很多玩家会把 ARG 作为解谜闯关游戏来体验。可以设计让玩家必须看剧情的方法(例如交互式的视觉小说,看完一部分再给新的文字),或通过表结局和里结局来区分真正看完剧情和仅完成闯关的玩家。

玩家分层

对于校园场景中发布的 ARG 来说,真正参与游戏的是少数,而在其他人解谜的过程中提供建议的或许是多数。为了提高前者的比例,我们调低了整体难度,希望有更多人能够真正加入玩家的队伍中。在这样的考量下,顶级玩家的游戏体验其实并没有得到满足。为了平衡这一点,或许可以做其他的设计(如排行榜、贡献度、成就收集、表里结局等)来优化。

参考

Jane McGonigal《Reality is Broken》

百度百科:平行实境游戏

从创造自己的 ARG 经历中 我获得的经验和教训(https://www.gameres.com/682593.html

打破虚拟与现实边界的游戏设计理论框架(https://mp.weixin.qq.com/s/9YmOEsw8g2Z9y7q7q0iiEA

“这不是个游戏”——ARG 的前世今生#1(https://indienova.com/u/lumen/blogread/12083

《Ronin》项目组留名

策划:多杰 / biaji / TuTu / lzq

设计:pai / young

前端:小龙

后端:橙子

项目组中仅有一位来自游戏组的同学,其他同学甚至没有游戏设计的相关经验,在兴趣的支持下最终完成了整个项目,真的非常不容易!

感谢鸬鹚、小星、鱼丸参与难度测试,感谢 TonyLi 的意见建议,感谢新闻与信息传播学院熊硕老师在复盘过程中的指导,感谢所有玩家的参与和支持!

]]> @@ -46,7 +46,7 @@ https://blog.bingyan.net/posts/aa80c571/ 2024-12-02T10:30:00.000Z - 2025-02-14T09:06:53.583Z + 2025-02-14T09:20:46.853Z
  • 本文是笔者阅读《简约至上:交互式设计四策略》之后的感悟。
  • 什么是“简单”?

    在了解简约化交互设计之前,需要先明白什么是“简单”。

      一类是我们在刚开始探索问题时,因为知识的不足而导致对问题并未深入理解,最终得出的想法过于简单化

      另一类则是我们对问题有了深入理解,看到问题的复杂性,可是在未掌握各种关联复杂性的情况下提出了复杂的解决方案。

      但当我们突然意识到某种底层模式并找到了更简单的解决方式时,便达到了第二类简单。

      简单并不意味着欠缺或低劣,也不意味着不注重装饰或者完全赤裸裸。而是说装饰应该紧密贴近设计本身,任何无关的要素都应该予以剔除。

                                                          ——Paul Jacques Grillo

      由此可以得出,真正“简单”的设计并非如同白纸一般“空白”而简单,简单并不意味着最少化。很多时候拋开极简主义,也能够成就简单。就算是看起来朴素的设计仍然具有自身的特征和个性,因此简单的特征和个性源自你使用的方法、所要表现的产品,以及用户执行的任务。

        举一个例子。有两把简单的椅子:一把夏克椅(Shaker chair),—把潘顿椅(Panton chair)。它们都把椅子的组件减到了最少。在设计它们的时代,都可以使用相应的技术轻易把它们制造出来。而且,它们解决了不同的问题:夏克椅耐磨,而潘顿椅方便堆叠。

      这两种椅子的设计简单、纯粹,但它们又各自具有完全不一样的特征和用途。

      用料、对关键要素的强调,甚至组合几个要素的方式,都会直接影响到最终设计。人们能够识别出差异,并为这些差异赋予相应的价值。

    简单的用户体验

      在了解了什么是”简单“之后,怎么以此为目标做出简单的产品呢?我们需要进一步了解怎么做出简单的用户体验。

      简单的用户体验需要基于用户的真实需求构筑设计愿景。

      首先是为主流用户而设计。

      我们将用户分为了三类。专家型用户:乐于深度探索你的产品或服务,并为你提出改进意见;随意型用户:拥有过使用类似产品或服务的经验;主流用户:使用你的产品只为了完成特定的任务。

      对于主流用户来说,他们会掌握关键功能的使用,但并不会有心力去熟练应用所有功能。简单易用的体验是他们的首选。由此看出,为主流用户设计显得至关重要。如若过于听取非主流用户的声音,可能会造成产品设计过于复杂,不易上手。

      再就是明确关键问题。

    ****  设计师在做设计的过程中,容易理想化,陷入自己的思维和设计理念之中,因而忽略某些重要的步骤,导致设计流程和结果复杂化。

      因而在做设计时,我们需要厘清几个关键问题:用户是谁?用户在这里的目标是什么?用户在这里真正要完成的任务是什么?以及为什么?对于设计者而言,又该如何在这方面下功夫?

      此外,设计者还需要走出办公室,深入到用户的使用环境中,对不同环境不同类型的用户进行调研并积累充足的信息。

      最后就是构建准确的设计愿景。

    ****  第一理解用户的深层次需求,关注用户在使用产品时的感受。通过设定简洁、真实、可信的用户故事,将抽象的问题转化为可感知的实际情境。

    在设计过程中,要考虑在设计极端情况下的可用性目标,而不仅仅是常规条件下的可用性。鉴于很多开始时简单的产品到最后都变得越来越复杂,很难使用。设定一个极端的目标,可以使你的产品随着时间推移越变越好(至少能够实现真正重要的目标)。瞄准极端的目标,即使是那些无法完全实现的目标,也能够帮你保持产品简单。

      基于用户的真正需求深入洞察和分析,同时听取每个人,尤其是利益相关方对设计的意见,尊重他们,允许他们参与其中,把所有利益相关方的目标都统一在最终用户身上,总结出正确的愿景。 

    交互设计四策略

      在知道如何制作简单的用户体验之后,我们可以对产品进行实质性的简约化设计。

      我们提出了交互设计四策略,其中包括删除,组织,隐藏和转移。

      在简化设计上,最显著的做法就是删除那些不必要的功能,让他们集中在达成自己目标的功能上。****执行删除策略时,首先要明确什么不能删。

      设计师要把握全局,确保只交付那些对用户体验真正有价值的功能和内容,即那些最能触动用户的功能和能够消除用户挫败感的功能。确认哪些可以删除。
    剔除残缺的功能,不要因为沉没成本就留存它,而是要评估每个功能的价值,考虑它是否会给用户带来负担,是否会增加维护费用,并深思为何要保留此功能。在设计过程中不要停留在假设阶段,而是主动听取用户反馈,了解该功能对用户是否真正有价值。给那些能轻松满足主流用户需求的功能分配优先级。
    专注于寻找能完全满足最高优先级用户需求的解决方案,找到后再考虑满足用户的其他目标。

      组织通常是简化设计的最直接方式。应用组织策略时,关键在于只突出一两个最重要的、用户最关心的主题。分块是最基础的方法。
    烦琐的功能通过分块组织成清晰的层次结构。分块越少,选择越少,用户承受的负担就越轻。之后就是围绕行为进行组织。
    提供用户操作指南,关注用户在使用产品时的首要问题:“我可以用它来做什么?”。理解用户的分类,以及他们在每个分类中想做什么,先做什么,后做什么。并且要明白简单的组织模式具有清晰的界限——是非分明。
    最简单的分类,通常指交叉重复性最低的分类方法。用户才能明确知道到哪里去找自己需要的东西。可以多找一些用户,询问他们的分类标准。

      部分功能的隐藏是一种低成本的策略,但究竟哪些功能能够隐藏呢?一般来说,只要不让用户找得太久,隐藏就是有效的。

      隐藏有很多方法:

    不常用但不能少的功能。
    如将个性化功能隐藏在设置里。

    采用核心功能+扩展功能的渐进展示。
    对于用户期望的功能,要在正确的环境下给出明确的提示。

    随着用户逐步深入界面而显示相应功能的阶段展示。
    如按顺序操作指南。

    适时出现。
    只在适当的时间、适当的地点显示相应的功能。如常用的字段解释。

    提示与线索。
    为隐藏的功能打上标签,如“更多”、“高级”,这种就像邀请一样的探索设计模式。

    让功能容易找到。
    把标签放在哪比标签的大小要重要得多,要确保用户在前进的过程中能遇到提示,别给他们挡路。

      需要提醒的是,隐藏只适用于非核心功能,但无论隐藏什么功能,都意味着你在用户和功能之间设置了障碍。所以在简化用户界面之前,必须全面了解软件中的各种功能,并且仔细权衡需要隐藏的功能,避免给用户造成不必要的麻烦。

      设计简单体验的一个秘密,就是通过转移把正确的功能放到正确的平台或者正确的系统组件中去。转移主要有两种方式:在设备之间转移。
    要识别多样性用户路径,不同的用户群体具有不同的行为模式,基于用户需求,考虑你想要支持的设备。向用户转移。
    设计师需要明确的是,哪些是用户擅长的,哪些是计算机擅长的。将用户有掌控感的操作赋予用户,让用户在简单的指挥下、通过计算机的操作感受到轻松。产品要为用户提供开放的体验,提供简单的工具让他们发挥想象力和创造力,让你的用户成为明星。

    ]]>
    @@ -77,7 +77,7 @@ https://blog.bingyan.net/posts/47e84996/ 2024-11-25T11:18:00.000Z - 2025-02-14T09:06:53.563Z + 2025-02-14T09:20:46.774Z   问卷设计是较为常用的用户研究方法之一,能够帮助人们快速的收集广泛的信息。下面几个步骤能够帮助你设计一份有效,科学的问卷。

    1.明确研究的目的、设计问卷架构

    这一步的主要工作便是
    定义目标,确定受众。在完成这两个工作后,我们便可以开始设计问卷架构。

    问卷架构:即问卷的结构,将问卷的目标划分为若干个构念(即不同维度的小目标),再对每一个构念再进行问题设计。

    下面是一份关于药品说明书改进的调查问卷的架构

    2.进行文献回顾和初步研究

    • 文献回顾:查阅相关的研究文献,了解已有的研究结果和常用的问题

    • 初步研究:通过访谈,小组讨论等方式收集初步数据,了解受众的需求和观点

    3.选择问题类型

    • 封闭式问题:提供固定的选项供受访者选择(如单选题、多选题)

    • 开放式问题:允许受访者自由表达意见(如填空题、简答题)

    • 量表问题:使用量表来衡量受访者的看法和态度(如李克特量表)

    下图为mbti测试中使用的李克特量表

    4.设计问题内容

    • 简洁明了:确保每个问题都清晰易懂,避免复杂的句子结构

    • 无偏见:问题应中立,避免引导性语言

    • 相关性:确保研究问题都与研究目的相关

    • 逻辑顺序:按照逻辑顺序排列问题,从一般到问题,从简单到复杂

    • 敏感问题:如果涉及敏感话题,要谨慎措辞,并在适当时候解释为什么需要这些信息

    5.点测:预测试问卷

    • 方法:SPSS软件中的信度分析

    • 衡量标准:克隆巴赫α系数(Cronbach’s alpha)

    • 小范围测试:在一小部分目标受众中进行测试,检查问题的清晰度和问卷的整体效果。

    • 收集反馈:根据预测试的结果,收集受访者的反馈,了解他们对问题的理解情况并对问卷进行检测。一般检测问卷的信度和效度。(非量表式大的问题无法进行信度分析,效度可以进行效度分析)

    • 信度分析:问卷结果的
      “稳定性”和“一致性”。信度表示同一个人多次填写问卷,结果能否保持一致。信度越高,问卷的稳定性越好。

    • 构念效度:测量的准确性,即测量工具是否真正测量了它所要测量的构念。它关注的是测量工具与构念之间的匹配程度、以及測量结果的解释是否符合理论预期。构念效度高的测量工具能够准确地反映构念的本质特征,为研究提供可靠的数据支持。

    下图为研究用户对恒温壶的满意度量表(满意度即为一个构念)量表B更为清晰科学。

    • 内部效度:指实验中的自变量与因变量之间因果关系的明确程度,即实验结果是否确实是由自变量引起的。而非其他无关因素的影响。简言之:研究在方法学上合乎逻辑、不受混淆因素影响的程度。

    • 外部效度:外部效度是指实验研究结果的有效性,即实验结果能被推论到实际情境中的程度。它关注的是研究结果是否具有
      普遍性和适用性,能否推广到更广泛的人群、时间和情境中去。外部效度高的研究不仅揭示了自变量和因变量之间的因果关系,还表明这种关系在更广泛的情况下也成立。

    • 方法:专家评审:请专家审核问卷的内容和结构效度,确保测量的准确性。使用专业工具如,SPSS

    • 修改和完善:根据反馈调整问题,删除不必要或有误导性的问题,改进问卷设计

    6.最终审查和定稿

    • 格式和布局:确保问卷的格式整洁,易于阅读,使用合适的字体大小和间距

    • 说明和指导:在问卷开头编写简短的说明,向受访者说明调查的主题和内容以及如何填写问卷

    • 隐私声明:如果适用,告诉受访者他们的回答将如何被使用和保护

    ]]>
    @@ -103,7 +103,7 @@ https://blog.bingyan.net/posts/8e4286a3/ 2024-11-22T09:27:00.000Z - 2025-02-14T09:06:53.565Z + 2025-02-14T09:20:46.791Z 对于 LayoutInflater 这个类,想必大家并不陌生。因为当我们学习 RecyclerList 这个好用的列表时,需要为这个列表编写适配器,在适配器里有这么两个覆写的方法,让我们在初学时很摸不着头脑:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val view = LayoutInflater.from(parent.context)
    .inflate(R.layout.fruit_item, parent, false)
    return ViewHolder(view)
    }
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val fruit = fruitList[position]
    holder.fruitName.text = fruit.name
    }

    我在初学时看到这两行代码也是眼冒金星,尤其是对于下面这行代码

    1
    2
    3
    LayoutInflater.from(parent.context)
    .inflate(R.layout.fruit_item, parent, false)

    这让我产生了三个问题:

    • LayoutInflater.from(parent.context)返回的是什么,为什么它的参数要传入 parent.context
    • inflate(R.layout.fruit_item, parent, false)的参数是返回了一个 View,然后这个 onCreateViewHolder 函数又以它构造了 ViewHolder 返回了,最后这个 ViewHolder 拿去干什么了?
    • inflate(R.layout.fruit_item, parent, false)的三个参数分别是什么意思

    要解答这三个问题,我们就要去学习 LayoutInflater 这个类以及它背后的布局加载功能。

    首先,我们要知道 LayoutInflater 是什么。简而言之,它就是将我们编写的布局 xml 文件文件加载成 View 的加载器。由于我们编写的移动软件都是在 Java 上运行的,显然 xml 这样的文件并不能运行。安卓的视图都是基于 View 类去显示的,屏幕的每次的绘制流程都是从 Activity 产生的 DecorView 开始递归遍历 View 树来进行绘制。但 View 类是十分复杂的,它的参数十分繁多,让我们直接在代码里去 new 一个 View 对象是非常不现实且不直观的。因而安卓的设计者想到了一个办法:我们可以提前编写一些静态文件,里面按照约定的格式编写好一个 View 的参数,再利用 Java 的反射特性,在运行时去读取该文件,生成相应的 View 对象返回。在这个过程中,这个静态文件就是 xml 文件,而读取该文件,生成相应的 View 对象返回的工作,就交给了 LayoutInflater 这个类。

    LayoutInflater 类怎么获取

    LayoutInflater 类是一个抽象类,因此我们不能直接构造它的对象,只能通过其提供的静态工厂方法 LayoutInflater.from(context)来获取。

    • 为什么要传入 context:每个 context 只需要对应一个单例 LayoutInflater,因而传入 context 是用来表明需要获取哪个 context 的 LayoutInflater 对象
    • 为什么要使用静态工厂方法:实现良好的接口封装,用户不需要知道实现了 LayoutInflater 这个抽象类的子类是什么,且这个子类对用户不可见,用户只需要关注 LayoutInflater 的功能即可。具体可看另一篇文章:Java 创建对象的深入做法

    这样,我们就回答了第一个问题。

    inflate 的使用和执行过程

    首先我们先来看 inflate 这个方法的定义

    1
    2
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot)

    • resource:类型为 int,代表要加载的布局在 R 文件中的值,通常为 R.layout.xxx
    • root:类型为 ViewGroup,若由 resource 加载出来的 View 为 temp,那么 root 便是 temp 的父 View
    • attachToRoot:类型为 boolean,代表是否要直接将该 View 添加到 root 的 children 列表里

    第一个参数很好理解,比较难以理解的是后两个参数。在解释这两个参数的用法前,我先简要地介绍两个重要概念:

    • 每一个 View 有自己的 MeasureSpec,attr,它们都是通过读取 xml 文件生成的,并且都不是具体的布局参数(布局参数是用来指导子 View 相对父 View 是怎样放置的,即该 View 相对父 View 的位置)
    • LayoutParams 是 View 中的布局参数,它的计算是根据父 View 的 MeasureSpec 和自己的 attr 来计算的

    总而言之,一个 View 只要有父 View,就会有 LayoutParams 参数

    那么接下来,我们结合源码来分析一下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    1. 结果变量
    View result = root;
    2. 最外层的标签
    final String name = parser.getName();
    3. <merge>
    if (TAG_MERGE.equals(name)) {
    3.1 异常
    if (root == null || !attachToRoot) {
    throw new InflateException("<merge /> can be used only with a valid "
    + "ViewGroup root and attachToRoot=true");
    }
    3.2 递归执行解析
    rInflate(parser, root, inflaterContext, attrs, false);
    } else {
    4.1 创建最外层 View
    final View temp = createViewFromTag(root, name, inflaterContext, attrs);

    ViewGroup.LayoutParams params = null;

    if (root != null) {
    4.2 创建匹配的 LayoutParams
    params = root.generateLayoutParams(attrs);
    if (!attachToRoot) {
    4.3 如果 attachToRoot 为 false,设置LayoutParams
    temp.setLayoutParams(params);
    }
    }

    5.temp 为 root,递归执行解析
    rInflateChildren(parser, temp, attrs, true);

    6. attachToRoot 为 true,addView()
    if (root != null && attachToRoot) {
    root.addView(temp, params);
    }

    7. root 为空 或者 attachToRoot 为 false,返回 temp if (root == null || !attachToRoot) {
    result = temp;
    }
    }
    return result;
    }

    其中得到完整的由目标布局加载的 View 的两行代码是:

    1
    2
    3
    4
    5
    6
    //创建最外层 View
    final View temp = createViewFromTag(root, name, inflaterContext, attrs);
    //以 temp 为 root,递归执行解析
    rInflateChildren(parser, temp, attrs, true);


    最终 temp 即是我们根据目标布局新创建的 View

    从源码中我们看出,inflate 这个函数后两个参数的 4 种情况是怎么处理的:

    • root=null,attachRoot=false:这种状况很好理解,我们要创建的 View 并没有指定的父 View,只是返回由 resource 创建的 View 便可以。因此它的返回值就是 temp,temp 的 LayoutParams 参数为 null
    • root=null,attachRoot=true:这种状况是被禁止的,因为它没有意义,因为你没有指定一个父 View,还想把它直接加到父 View 的 children 列表里
    • root!=null。attachRoot=true:这种状况也很好理解,我们为它指定了父 View,也希望将它直接加入父 View 的 children 列表里。在这种情况下,它会根据父 View(root)计算好 temp 的 LayoutParams 的值,然后直接将 temp 加入父 View 的 children 列表里,最后将 root 返回
    • root!=null,attachRoot=false。这种情况稍微难以理解,它指定了父 View,但不希望将它直接加入父 View 的 children 列表里。在这种情况下,它仅仅会根据父 View(root)计算好 temp 的 LayoutParams 的值,然后直接将 temp 返回

    至于在 createViewFromTag(root, name, inflaterContext, attrs)这个函数里,究竟是怎样通过反射读取文件创建并返回 View 的,我们在这里不深入讨论,有兴趣可以去看一下这篇文章:Android | 带你探究 LayoutInflater 布局解析原理,从源码的角度讲得非常详细。

    至此,我们回答了第三个问题,就剩下第二个问题了。

    RecyclerList 适配器的覆写函数

    让我们重新看这两个覆写函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val view = LayoutInflater.from(parent.context)
    .inflate(R.layout.fruit_item, parent, false)
    return ViewHolder(view)
    }
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val fruit = fruitList[position]
    holder.fruitName.text = fruit.name
    }

    首先看第一个函数,经过上面的介绍,我们可以很清晰地得出,这个函数先使用 LayoutInflater 加载了 RecyclerList 每个子项的布局,并指定其父 View 为参数 parent,获得其 View,再构造 ViewHolder 返回。

    由此我们可以做出下面两个猜想

    • parent 的参数就是 RecyclerList 这个 View 本身,因为 RecyclerList 的每个子项的父 View 应该就是它本身。这点我们在源码里找到了答案:
    1
    2
    holder = mAdapter.createViewHolder(RecyclerView.this, type);

    • 这个 ViewHolder 按需创建(屏幕当前需要展示多少个 item 就创建多少个),然后将其放到了某个 List 里,RecyclerList 再在合适的时机将它们加入到其子 View(children)列表中,便完成了一个列表 View 的创建。这点我们也在源码中找到了相应的佐证:
    1
    2
    3
    4
    final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
    ...
    ChangedScrap.add(holder);

    至于第二个函数,很明显,它是在 onCreateViewHolder 函数之后执行,根据 position 的对应关系,将 list 的内容填入每个创建的 item 的 View 中。

    至此,第三个问题全部回答完毕,相信你之后再写 RecyclerList 的适配器时,再也不用去回忆该怎么写了。

    ]]>
    @@ -128,7 +128,7 @@ https://blog.bingyan.net/posts/bfd3146a/ 2024-11-21T07:55:00.000Z - 2025-02-14T09:06:53.591Z + 2025-02-14T09:20:46.892Z 《星空链接》是一款战棋玩法的卡牌游戏,玩家需要召唤精灵并指挥他们的进攻与移动来赢得胜利。本期会对这款游戏进行一个简单的概念与印象介绍,不会深入讲解过于复杂的游戏规则部分。

    《星空链接Star·Link》由作者启明星ZS自行创作研发,创作周期超过三年,美术素材均由AI生成,自学P图技能制作卡框。出于对AI做图的考量以及目前的完成度与较快的迭代速度,目前暂未考虑参与商业上架等行为,但是欢迎加入官方水群参与进一步讨论(具体可以直接前往B站搜索),本人为群主。

    《星空链接》是一款战棋玩法的集换式卡牌游戏,玩法参考了游戏王、皇室战争等。

    游戏中有五种类型的卡牌,其中精灵卡起到承担主要的攻防交互与输出的作用,链接卡可以用于激活位点(精灵只能在激活的位点召唤),建筑用于防御,攻击卡用于下达进攻指令,法术卡辅助决斗。

    卡垫左侧是星能标记区,右边放置卡组、弃牌区和流放区,卡垫最下方是玩家核心区,也是对方玩家的进攻目标。

    使用角色卡,扮演不同的角色参与决斗,对标《三国杀》的武将牌,或者《炉石传说》中的英雄。

    精灵卡上携带的信息有:卡名、星级、元素、卡色、数值、效果、稀有度、字段、种族、卡牌种类。星空链接中有七大元素:冰风水火雷光暗,以及无属性。

    在设计的过程中,我所遵循的主要思路有:简化数值计算重逻辑机制、通过链接卡+王牌进行操作引导、引入一定的限制来框定玩家的操作空间与上限同时减少决策量等。

    首先是数值方面:最开始的设计中一名精灵有五个数值,攻击距离攻击力双防(物防和特防)和生命值,每名精灵的攻击力会在物攻和特攻中分配一个,比如A精灵是物攻手,和A战斗时防御方采用物防进行伤害计算;B是特攻手,和B战斗时防御方需要用特防进行伤害计算。在一次进攻行为后,玩家需要经历【查看攻击力与对应防御力】【通过加减计算得到伤害数值】【将伤害与生命值进行加减得到剩余生命值】三个计算步骤,考虑到在星空链接中不可能只涉及寥寥数次计算(事实恰恰相反,类似的进攻行为在游戏中会经常出现),我们不应该将单次的进攻伤害计算步骤流程做得如此复杂,而是尽可能简化数值运算,并强调卡牌本身的机制效果,即【轻数值重机制】的设计逻辑。所以在最新版的设计中我去掉了与防御力有关的数值体系并重构了数值系统,仅保留了攻击距离、攻击力和生命值,现在的伤害计算被缩减到了一步:生命值减攻击力。

    但是我需要说明的是,是否保留防御力体系完全是出于权衡利弊之后的考量,而不是一个可有可无说删就删的无用体系。引入防御力体系后,整个游戏的数值系统会更加立体而丰富,我们可以设计高防低血或者低防高血的精灵,而且还可以围绕防御力设计相当丰富的机制;只是在考虑整体的游戏复杂度以及经过了大量测试后,最终才认为删去防御力体系是利大于弊的,这并不是脑子一热就可以立刻拍板的事情,修改超过三百张卡牌的数值体系本就是一个大工程。

    其次是引导方面:我有意在卡牌设计时加入了部分高代价高收益的王牌卡片,这类卡牌的出场几乎可以左右一场对局的胜利,这些王牌卡的强度和杂兵比是断层级别的。当然,这类卡片各自都有更加苛刻的登场条件,通过这种设计可以有效引导玩家去做出类似的操作来推进做场。比如A卡组的王牌需要献祭场上的三个杂兵才能登场,那么A卡组的玩家就会试图采用多种方式尽可能增加我方场上杂兵的数量;B卡组的王牌可以造成暗属性伤害,那么B卡组的玩家就会围绕暗元素投入对应的辅助卡牌。链接卡也是同理:玩家可以在开局时自选一张链接卡加入手牌,一套卡组会投入两到五张不同的链接卡,选择怎样的链接卡开局,玩家便需要透彻理解对应链接卡的效果特性,做出合理的决策。比如选择一张雷系的链接卡作为初始手牌,这张卡可以在我方造成了雷属性伤害后获得增益,便可以用这种方式鼓励玩家去尽可能造成更多的雷系伤害。

    最后是限制方面:玩家只能在链接卡激活的位点召唤精灵,这样既可以限制玩家的操作上限不至于出现过于混乱的情况,同时又可以减少玩家在面对复杂情况时的决策量,玩家不需要思考精灵需要在十五格位点中的哪一个召唤,只需要在已经激活的位点中选择即可。通过这种方法,可以把原先复杂的决策流程拆分成【放链接卡】【放精灵】;两个步骤,进一步增强对局的稳定性。

    攻击卡的引入也是一个例子。精灵不能进行无限制的攻击,只能使用攻击卡攻击,这也使得星空链接中的进攻时机是非常宝贵的,这也要求玩家做到不出手则已一鸣则惊人。但是需要指出的是,这样的设计会限制一个卡组的输出上限,从而倒逼设计师寻找其他的输出方式,乃至于喧宾夺主;比如我们的精灵本身自带破坏对方卡片的效果,那么在这个效果面前攻击卡的诸多限制便会产生负反馈,玩家还不如直接使用效果破坏卡片,而不是使用复杂的攻击卡。所以针对这种情况,可以设法提高单张攻击卡能够带来的收益,同时围绕卡牌资源的回收运转利用来做文章。我希望的最理想情况是:攻击卡主要为卡组的王牌服务,杂兵则使用其他的方式清除,并且采用【王牌精灵使用王牌攻击卡造成惊人伤害】的方式增加游戏的观赏性,也为玩家提供一个炫酷的追求点。如何在最合适的时机召唤卡组的王牌,并让王牌使出招牌的攻击卡,便是一个令人振奋激动的追求。

    以上便是对星空链接的概况介绍,对于具体细节规则感兴趣可以直接前往B站搜索相关视频,up主启明星ZS是本人。谢谢。

    ]]>
    @@ -153,7 +153,7 @@ https://blog.bingyan.net/posts/7f6332a1/ 2024-11-20T13:02:00.000Z - 2025-02-14T09:06:53.594Z + 2025-02-14T09:20:46.907Z 一.老年群体用户特征

    1.老年群体划分界限

    按照年龄分段划分:45岁以下为青年,45-59岁为中年,60-74为年轻的老人或老年前期,75-89岁为老年,90岁以上为长寿老年人。按照国际规定:65周岁以上的人确定为老年。在中国:60周岁以上的公民为老年人。人过50后,时间感觉走得就更快了。尤其是退休后,不再承担社会的功能角色,这种被时代遗忘的孤独感更加强烈。

    2.适老化产品应用场景

    经调研,老年用户对社交通讯、新闻资讯、生活购物娱乐这三类App使用频率最高。可以推测出老年用户在上网过程中最常用的功能是:出行需要(健康码、行程码)、医院挂号、银行业务办理、购买车票(机票、火车票)、线上缴费等。

    网约车

    ****问题:首次使用app时如果未设置支付方式,可能会涉及跨多个app完成支付

       医院挂号

       多数老人只能在窗口排队,需要非常早到医院排队。老年高频的病以及主任医师的号难以拿到。

    网络支付

       线下基本没有现金支付了,而手机支付不会用。对于网络陌生,内心保持不信任。

    出行票

       老年人不会使用购票软件,在不知是否有票的情况下也只能去车站窗口排队碰碰运气,退票、改签等也只能跑窗口。节假日购票需求量大,去窗口买到票的机率非常小。 

    3.操作困难的原因

        随着年纪增长,老人的肌肉会流失出现肌肉无力的症状,导致老人的动作幅度减小,在手机上进行精确细致的操作难度很大,极容易出现误触的可能。目前智能设备大多都是电容屏,原理主要是电阻式和电容式。电容式就是将人体充当一个电极,在人触屏的时候会改变电容。老人细胞水分减少,皮肤皱纹加深,使得使用触屏灵敏度大大降低,哪怕是正确的手势,可能也会很难完成操作目标。


    二.适老化的视觉设计方法

    设计师是团队中为数不多可以站在用户角度思考问题的产品角色。所以不应把适老化设计成一味的音量大、字号大、大设备。不考虑兼容性,可能会出现由于字号过于大,页面其他元素都“跑了”位置,验证码也错位了。不仅没降低难度,反而带来困扰。

    1.使用非衬线体

       字体的选择上,规则是减少不必要的视觉干扰,保证易读性,更适合老年人。字体分为衬线体和非衬线体衬线又被称为“字脚”,衬线体就是指有边角装饰的字体;非衬线体则与衬线体相反,通常是机械和统一粗细的线条,没有边角的装饰。所以常规的非衬线体可读性更强。从视觉角度上看非衬线体比衬线体更大、结构更清晰,所以建议长文阅读或小屏幕更偏好使用非衬线字体;衬线字体主要用来提升短句美感,更多用于标题或者小篇幅文本。

    2、最小字号16pt

        确认最小字体的方法是通过肉眼到物体之间的距离物体的高度以及人的最小可接受视角,构成一个三角函数关系。对于普通用户,最小视角0.3度时的阅读效率最好,一般眼睛距离电脑屏幕为50cm,根据三角函数公式能算出合适的字高。对于不同的屏幕可以通过两轮换算(cm到inch,inch到像素)得出最小字号。经过计算得出,字号最小采用16像素。一般产品设计大多使用Regular和Medium两种字重,对于老龄化产品,考虑易读性,可以通过在原本基础上再提升一个字重,加粗文本的方式使文字轮廓更加清晰。

    3、自由调节字号大小

        上面计算出的16pt字号也仅仅适用于大部分人群,不排除16pt都未必可以满足他们的可能。在ios系统设计显示与亮度中,可以修改文字粗细,调节文字大小。微信里也有类似的功能。目的就一个,可以根据自身视力做对应的选择,提供优质的用户体验让各种用户都可以便捷使用产品。

    4、1.5-1.8倍的行间****距

         在移动应用中,应可对文字大小进行调整,段落内文字的行距至少为1.3倍,且段落间距至少比行距大1.3倍,同时兼顾移动应用适用场景和显示效果。通常标题行间距为1.3倍,文字行间距为1.5-1.8倍,视觉效果是最舒展,舒适的。

    5、强对比度颜色

         用户通常会根据自己的生活习惯、大脑认知去判断事物本身。所以做设计时应尽量降低学习成本。可以借鉴真实存在的物品设计,保持和老年人现实生活认知是一致的。比如生活中常见的红绿灯,红色表示禁止,黄色表示警告,绿色表示通过。

    增强颜色对比度:文本的视觉呈现以及文本图像至少有4.5:1的对比度。

    -对比度(AA级):文本的视觉呈现以及文本图像至少7:1的对比度;大号文本(字重为Bold时大于18px,字重为Regular时大于24px)以及大文本图像至少有4.5:1的对比度。 

    -对比度(AAA级):文本的视觉呈现以及文本图像至少要有4.5:1的对比度;大号文本(字重为Bold时大于18px,字重为Regular时大于24px)以及大文本图像至少有3:1的对比度。


    6、图标设计

        图标是APP必备的元素之一。通常最直接适老化方式是增加图标和按钮的尺寸大小,达到易操作性、易读性。还有一点容易忽略,尽量采用拟物化、通用化的设计,图标表达尽可能具有老年人的生活时代特征,符合他们的认知习惯,图标越具象,老年人使用起来越方便。建议搭配文字描述,更便于清晰、快速理解。

    三.适老化的交互体验

    1.操作手势以及定义

        人机交互中有诸多操作手势,介于不同的使用目标、场景,交互手势设计也大相径庭。总结下来主要是以下几种交互方式:单击(Click)、双击(doubleclick),长按(long press)、拖拽(drag)

    手指贴合上屏幕的时候,手指与屏幕的贴合面,并不是正圆形,均匀向四周扩散的,而是向下的扩散更大一些的椭圆形。以触摸中心点为基准触摸的过程中会有向下的一个偏移。

    2.操作低难度低精度

    可点击

        根据费茨定律,任意一点移动到目标中心位置所需要的时间,与目标距离正相关,与目标大小负相关。因此适老化界面设计中需要放大目标以至于足够清晰,便于用户精准快速的触达。指尖的长度为8-10mm,所以10mmx10mm就是一个最小触摸目标尺

    怎么点

        据研究,点击,上下滑动,左右滑动,放大缩小这几个操作是老年人接受度更高的。所以在设计交互时,尽可能使用老年用户熟悉的交互方式,减少复杂的交互方式。

    低难度

        老年人身体运动机能下降,导致很难完成长距离、长时间、连续的精准的操作。所以在界面设计时候,尽可能设计在下半部分。保证手指移动最短距离。减少精准度非常高的手势操作。

    3.有效反馈提示

        老年用户对识别动态信息的捕捉即刻感知明显下降。因此,在界面设计中如果涉及到短暂垂岸的提示性信息,在原本停留的时长基础上延长一截。比如提留时间小于3秒,那么就延长到3秒;提留时间小于3-5秒,那么就延长到5秒;提留时间5-7秒那么就延长到7秒。如果不在这个范围内的时长,那么可以提供关闭机制。

    4.语音功能

    增大音量

        老年人伴随的身体特征下降还有听力,通常做法是适当增大音频/视频的音量。研究表明,老年人听觉平均感知音量在67.5~75.3分贝之间。

    降低语速

        保证老年人有效的接收到声音信息并进行理解,音频/视频的播放速度也需要适当放缓慢。提供慢速、正常、快速3档语速,让老年人自己选择适合自己的速度,更有效的获取声音信息。

    AI技术辅助输入

        现实中,很多老年人文化程度不高,不会使用输入法,甚至拼音都不会,大多都选择了手写这种输入方式。但手写这种方式存在很严重的识别问题。再加上肢体运动能力的退化其实手写也并不算是真正的好体验,就是折中的方案。可以借助AI技术,提供语音输入功能。老年人可以方便的通过语音和图像搜索主动探索获取新知,答疑解惑,跟上时代进步。

    5.选择代替输入

        选择类的交互方式远比输入类提升效率大很多。所以尽可能避免繁琐的打字过程。尽可能选用选择框代替输入。假如产品内的信息是通用的,可以在输入框提供默认选项。

    6.通俗易懂的文字

        由于老年人触互联网网时间短、受教育程度不同,难以避免互联网术语理解力不强。设计时需要对专业术语都进行了口语化,采用通俗易理解的文案。

    7.给老人信任感

        老年人由于触网时间短,对网络充满陌生感。需要对一些官方内容增加标签(或保障)来提升产品整体的可信度,增强对产品的信任感。比如老年用户多少都伴随着身体健康问题,大概率会使用医疗产品,给医师、医院提供专业官方认证机制以及前端展示,让老年人用的踏踏实实。

    8.缩短操作链路

        老年人大多处于浅层触网状态,掌握的APP功能很少,在使用新产品时候,会出现’我在哪儿”“我在做什么”“接下来我要做什么”等困扰。有两个解决办法。1.以适当提供操作引导,告知用户所处的位置2.减少非必要操作,避免非必要场景干扰

    此文章引用站酷designlinik

    ]]>
    @@ -179,7 +179,7 @@ https://blog.bingyan.net/posts/655fd376/ 2024-11-09T09:43:43.000Z - 2025-02-14T09:06:53.556Z + 2025-02-14T09:20:46.758Z

    本次分享有不少概念和名词。如果你看完还不太懂,没关系,可以先学点前置知识;如果你没看就懂完了,没问题,下次分享就你了

    神秘的网络

    提到计算机网络,我们可能马上会想到什么 OSI 七层模型,三次握手四次挥手,在浏览器输入 URL 之后发生了什么之类的知识。考虑到我们既不需要像搞通信设备的那帮人天天研究网卡和交换机,也不需要像搞基础架构的那帮人天天研究协议和路由算法,我们的网络知识似乎已经够用了……吗?以下是我在实践中遇到过的与网络相关的问题:

    • 开启 Clash For Windows 里的 TUN mode,我获得了全局代理的能力,但是为什么 DNS 解析结果都变成了 198.18.x.x?
    • Wireguard/Zerotier/Tailscale 真神奇,轻轻松松组建大内网,但是怎么合在一起用就爆炸了?
    • 我的国内小鸡也想看看太平洋彼岸的世界,有没有简单快速一劳永逸还不影响服务器正常访问也不影响 Docker 甚至 K8s 的方法?
    • 有公网 IP 但是还是连不通,到底是配置的问题,还是防火墙发力了?如何进行排查?

    了解一些 Linux 网络相关的知识,虽然不能完全帮助我们解决上面的问题,但也能提供点思考和分析的方向,还是很值得学习的。

    网络协议栈

    首先我们还是从基础的网络通信开开始,了解数据是如何从程序发出,经过网络设备,传输,最后被另一个程序接收。好消息是,这个过程和我们所熟知的分层模型基本上是对应的,数据流动的路径大致如下:

    • Socket:应用层的各种网络通信基本上都是通过 Socket 编程接口来和内核的网络协议栈进行通信的。外加 Linux 一切皆文件的设计哲学,对 Socket 的操作就和对文件的读写一样
    • TCP/UDP/IP:传输层和网络层,前者负责报文的封装以提供可靠的/不可靠的数据传输服务,后者负责给数据加上 IP 地址等路由信息,这和我们在计算机网络里学到的基本上是一样的
    • Device/Driver:在 Linux 的设计中,数据链路层被分为了面向系统的 Device 和面向硬件的 Driver。Device 作为一种抽象接口,其背后的实现可能是真实的物理设备也就是 Driver,也有可能是某个程序,甚至是本地访问的回环设备(Loopback Device)。而面向硬件的 Driver 也就是驱动程序,功能包括初始化和配置硬件设备等。一般来说,Device 会准备好实际需要发送的数据,如 MAC 地址等,再调用 Driver 中具体的网络设备函数

    经过上面的过程,我们得到了一个完整的数据包:

    接收方的处理大概就是上面的过程反过来,这就不细说了

    由于这个过程中有比较明显的逐层调用,逐层封装,再后进先出处理的特点,与我们熟知的栈这种数据结构很类似,因此它也被称为 Linux 网络协议栈。

    管理网络数据包

    在前面的网络协议栈中,我们会发现除了应用层,剩下的都在系统内核空间里了。这样的设计在数据安全与隔离上确实很好,大黑客没法直接窃听或者伪造网络通信了,但我们自己想要管理和控制,似乎也不简单。还好,Linux 内核还给我们提供了一套通用、可编程的网络数据包管理框架:Netfilter

    这套框架在数据包处理的过程中埋下了五个钩子(Hooks),应用程序先在这注册回调函数,每当有数据经过时就会触发调用,最终实现干预网络通信的功能。这五个钩子是:

    • PREROUTING:进入 IP 路由之前触发,只要接收到的数据包无论是否发往本机都会触发
    • INPUT:经过 IP 路由之后,确定发往本机的就会触发
    • FORWARD:经过 IP 路由之后,确定不是发往本机的就会触发
    • OUTPUT:本机发出的数据包,经过 IP 路由之前触发
    • POSTROUTING:本机发出的数据包,经过 IP 路由之后触发,包括本机程序发送和转发的数据包

    有了这套框架,网络的玩法就变多了。比如被称为 Linux 自带防火墙的 iptables,比 iptables 更新更快的 nftables,kubernetes 中实现 Service 访问的 kube-proxy 等等,很强很灵活。

    其他方法比如 proxychains 通过拦截库函数调用,在进入内核前修改,wireguard 通过虚拟网络设备捕获 IP 数据包并完成加密传输,还有最新最热的 ebpf 在内核中拦截,这里就不细说了

    虚拟网络

    前面提到的网络能力已经非常丰富了,但物理的网络毕竟还是有基础设施的限制,随着对灵活性、安全隔离、运维管理和云计算的需要,网络的虚拟化也变得重要起来。在物理网络的世界里,我们有网卡、交换机、路由器,而在虚拟网络中,我们也同样拥有:

    • 虚拟网卡:TUN,TAP,VETH
      • TUN 作为一种虚拟网络设备,工作在第三层,可以接收和发送 IP 数据包
      • TAP 作为另一种虚拟网络设备,工作在第二层,可以接收和发送以太网帧
      • VETH 是另一种虚拟网卡的方案,成对出现,用于连通不同命名空间的网络

    我们可以用 TAP/TUN 设备实现一个简单的 VPN:

    我们也可以用 VETH 简单的连接两个容器:

    • 虚拟交换机:Bridge
      • 工作在二层,根据 MAC 地址转发数据包

    虽然他叫 Bridge 直译过来是网桥,但从功能上我们完全可以把它当交换机来用,另外 Linux 内核其实也带有路由功能,看做个自带的虚拟交换机好像也没啥问题

    利用 Bridge,现在我们可以把多个网络连接到一起了(docker 的默认网络模式就是这样做的):

    但是,虚拟网络的大网还少了点东西… 是的,他目前只能在本机完成。为了使我们的虚拟网络跨越不同的物理主机,简单来说,我们可以:

    • VLAN,早期的虚拟局域网技术,在二层中添加 VLAN Tag 实现,但其存在子网数量有限、容易引起广播风暴、配置复杂等不少问题

    • VXLAN,使用 L2 over L4 的方法,将二层的以太网帧放到四层的 UDP 协议报文中,简单易用,三层可达就能传输(flannel 的默认模式就是这样做的)

    至此,我们的虚拟网络算是用比较现代的技术搭建起来了。当然,近些年云原生分布式大火,虚拟网络也成为热门研究领域,实现方式除了 vxlan 还有很多,感兴趣的话可以看看那些热门的 k8s 网络插件。

    附:你可能不知道的校园网小知识

    • 认证的时候在学号后面加上 @hust,可以在断网/欠费的时候获得 1 次 10 分钟,每月最多 3 次的连接
    • 某些地方的网口插上是有公网 ip 的,但是还得要校园网才能访问
    • DNS 协议的 53 端口没认证也可以正常通信,这意味着你可以在校内某台有网的设备/云服务器上用 53 端口搞个 wireguard 之类的,来实现不间断的校园网访问(武大的网络也可以这样干
    • 认证时密码是加密后再提交的,但这个加密算法(RSA?)和时间无关,这意味着你可以把登录请求截获下来 curl 发一下就可以自动认证了
    • 某些运营商推出的校园套餐可以直连校园网
    • 成为学生网管可以享受不断网的免费校园网!
    ]]>
    @@ -208,7 +208,7 @@ https://blog.bingyan.net/posts/8cee95b9/ 2024-10-31T08:12:00.000Z - 2025-02-14T09:06:53.569Z + 2025-02-14T09:20:46.794Z 在注意力资源成为一种稀缺资源的当下,如何争夺注意力,获取注意力背后的价值,是各路媒体、商家都竭力研究与完成的事。

    于是,醒目而有吸引力的标题,则成为了争夺注意力的重要手段。
    尤其是对于公众号、小红书等以静态图文为主要传播形式的媒介而言,如何取一个有爆点、能抓住人视线、又能契合文章内容的标题,是每位运营人的必修课。

    当然,跟所有的内容一样,起标题也是可以学习的,大热标题的逻辑模式都能大体被总结出来,进而模仿学习。

    前段时间,有位博主发布了这样一段内容,总结了目前众多媒体的文章选题方向与标题,引起热议,其中还不乏一些媒体从业群体的“破防”。

    我们不谈这些争议和媒体行业的鄙视链,但可以从些得到共鸣的内容中总结出一些起标题的思路。

    一.模板化标题

    就拿这些被提到的类型来说,
    这是一种概念化的标题法。

    《一个X决定去做Y》《一个X的Y》《X,困在Y里》《一个X,与Y》《X不欢迎Y》《X,在Y之后》《X岁,开始做Y》

    一般适用于人物性的故事,把文章主旨内容找出主谓宾提炼出特点归纳成上位概念,套上这种已经有一定读者印象的模式句子,只要其中概念内容存在一定的焦点性、反差性,就能精准定位到读者群体,同时引起部分读者的兴趣。

    这类标题的好处在于能用很精炼简短的句子达到最大程度概述文章内容,从而标题模式的普适性使得它是一种稳定的、不出错的标题方式,正因此,这种标题模式被广泛运用在各大非虚构写作的媒体中。

    以下标题分别来自 正面连接 人物 真实故事计划pro

    我们不得不承认,这种标题在叙事性的内容中极具吸引力,但是另一方面,对于一些小的、素材群体没有那么广泛的内容创作者来说,素材内容无法达到足够的反差、视野没有那么开阔,这类标题便无法起到应有的效果。

    **二.**放大标题情绪

    在短视频、碎片化信息为主流的当下,人们需要都需要短而快的即时刺激,而文字表达逐渐成为一种比较被动的方式,因而,文字膨胀在互联网上变得泛滥。以前打哈哈表示快乐,现在要打“哈哈哈哈哈哈”,太、绝、最、非常,
    这种较为极端的夸张表述成为一种比较普遍的吸引注意力的方式。

    通过在标题中用这种极端化的表达和带有强烈情绪色彩“!“”?”等标点符号去放大情绪也成为一种可以套用的标题思路。

    这种风格的标题在偏向娱乐性的内容输出中得到广泛使用。

    当然,这类标题往往不免被诟病太过营销号、标题党,但放在趣闻类型、强共鸣类型的吐槽风格中,不失为一种十分有效的方式。

    这种强烈的情绪观感能激起人的兴趣,击中读者心中的情绪痛点,在社会压力不断增大的当下,是一种十分有效的标题方式。

    这种一个击中要害的标题,未必是整篇文章都在关注的问题,可能只是一个因子,但的确是最吸引读者的、情绪最高昂的部分。

    三.互动和留白

    内容输出归根结底要跟不同的人产生思想、观点的分享与碰撞。
    因此,在内容输出中要始终记住,创作者不应该沉溺在自己单方面的输出中,这样无法完成表达的闭环,是一种无效的输出。所以,和读者保持互动性就显得尤为重要。互动性的标题可以让读者产生熟悉感、共鸣感和进一步了解的想法,同时让读者感觉到自己不是单方面被输出,而是得到一种“被看见感”。

    以3号厅检票员工这一篇内容为例,把读者放在一个可视的角度,拉高读者的期待又用“偷偷发”增强了和读者互动交流的感觉。

    在和读者产生互动的众多方法中,留白是最便捷且最吸引人的内容。人天生就具有好奇心,通过适当的留白,引发读者对内容的好奇,引导读者一步一步走到作者的逻辑场景中。

    这种留白可以使广泛使用在情感、趣闻等多种类型。是一种很具有参考性的标题方式。

    当然,取标题的方式远远不止以上提到的几种,
    当一篇内容质量足够好、情感足够引起共鸣、文字足够扎实时,取标题其实不需要太多的技巧,只需要真诚的阐述就好。

    但在大多数时候,标题都是打开我们与读者沟通的大桥的敲门砖,这些公式更给大家一些固定的思路,但更多时候,好标题往往需要天时地利,可能是灵光一闪的妙言,也可能是足够灵气或纯粹的击中了目标读者。

    最后,祝福所有运营人都不会遇到想不出标题的时刻!

    ]]>
    @@ -233,7 +233,7 @@ https://blog.bingyan.net/posts/4df76a6/ 2024-10-24T01:02:00.000Z - 2025-02-14T09:06:53.559Z + 2025-02-14T09:20:46.770Z

    笔者今年从冰岩作坊游戏组毕业,5~9月在逆水寒手游组完成了白帝城流派挑战BOSS楚余音普通难度和英雄难度的迭代和制作(初版的设计文档非本人所写),目前已经投入到下一个BOSS的设计和制作当中,本文为整体制作流程的简介和整体的复盘,并反推了一些设计阶段应该做好的事情。

    BOSS制作流程

    前期设计

    • 在制作BOSS之前,会先确定副本场景和战斗场地,但由于流派试炼会使用已有的流派地图,所以省去了场景设计和制作这一步,只是为了战斗场地和根据副本流程对场景内的物件做一些修改和增补。

    • 由于场景和BOSS角色是前期就规划好的,通常BOSS负责人接到的是具体的BOSS设计需求(外观和整体特点均确定)。

    • 然后就到了脑暴阶段,个人认为脑暴阶段的梳理非常重要,脑暴不是想到什么就写什么,而是要先明确当前副本的体验重点,然后明确BOSS在整体副本中的定位,最后也是最重要的,是明确这个BOSS的设计重点,并提炼出相关的元素。首先技能要体现神相流派的技能特点同时做升级,其次再从神相流派本身出发,提炼特点,例琴、琴音等,这时候的发散可以不考虑合理性,想到的相关的都可以罗列上去。

    • 之后大家会约一个脑暴会,分享所负责的boss的想法,同时对其他人的boss提出一些想法,最重要的是这时候要多听取大家得想法,并且要做好记录。

    • 脑暴会开完之后,首先要梳理会上的内容,整理下哪些方向是大家觉得ok的,哪些是不建议做的,然后明确设计目标,例如要体现角色生平,例如展现特定元素或者玩法等等,然后对想法围绕上面的设计目标进行一波整理,要勇于放弃那些不合适的想法。

    • 然后再给予自己1~2天的时间找参考,脑暴,获取灵感,之后就是继续整体,提炼出BOSS战斗的核心(机制、阶段)。

    • 之后就是设计具体的技能,需要围绕前面的战斗核心来设计,并且考虑到技能之间的配合。一个简单的AOE,围绕不同的机制,和不同的技能搭配配上不同的包装也能带来很多不同的体验。其实设计技能还要考虑到很多,希望等下一个自己设计的BOSS上线了再做分享。

    • 然后就是整个战斗阶段的编排,要多脑测,包括在后续施工的过程中也要多加注意,不要把精力都集中在机制和技能设计上而忘了整体的流程,比较整体的流程和节奏是影响玩家体验的重要内容。

    提需求

    • 设计好了之后,就要提需求,要提的需求有如下:角色需求;场景需求;动作需求;特效需求;交互需求;程序需求;文案需求;音效需求;动画需求;数值需求。

    • 提需求很重要的一点是要知道,你提出来的需求,会转变为其他的工作量,所以请务必多思考,谨慎的提出需求,同时要考虑到性价比。

    • 在游戏行业中,做需求的人可能游戏体验并不是很丰富,也可能完全不知道你的BOSS的战斗体验,所以请务必描述清楚你的需求,并且尽量避免使用一些boss相关的代称,并尽量提供合适的参考(引擎中+其他游戏)。

    施工

    • 施工前对BOSS制作周期确认,明确每个月的进度目标,再按照每个周进行详细拆分,要体现在具体的单子上,方便团队进行管理。

    • 在通流程前大家也要频繁沟通,多对齐进度,check当前BOSS的体验方向和具体的施工表现。

    • 在通流程之后,不要花过多的时间精力去迭代,因为迭代是没有尽头的。

    • 多测试,收集各个水平同事的反馈。

    上线

    • 上线后要时刻注意玩家的反馈,注意一些恶行bug的修复,最重要的是看当初的设计目标是否达成,玩家在面对机制是怎么想的,面对技能是怎么处理的,是否和最初设想的一样,如果不一样,那是为什么呢?

    BOSS制作复盘

    前面说了那么多假大空,是时候来说一些问题了,由于保密原因,就不说具体的问题了,而是集中说一下改进方法。

    BOSS设计

    问题一:BOSS整体的机制设计

    • 提升方法:

    • 对逆水寒手游已有BOSS进行招式拆解,梳理不同机制的交互方式以及玩家角度需要进行的操作。

    • 多玩MMO以及其他包含战斗体验的游戏,积累游戏设计库,尝试每天一个BOSS招式设计(先坚持100天试试),灵感可以是游戏或者其他任何作品。

    • 设计时如果是从某个叙事角度出发的,需要考虑到玩法和叙事主题的适配度,如果是从玩法出发的,需要把握主干的体验,让其他技能为目标服务。不要添加特别多额外的目标,而是围绕技巧点添加有意义的技能(部分可调和的技能只是看起来表现和目标不一样,玩家体验起来都是走到一个地方按下几个按键,体验仍旧会感到重复)。例如如果主要体验是玩家调和,那就把调和这个操作拆解成原子玩法,思考BOSS怎么在每一个环节做干扰,从而衍生出不同技能,还可以思考玩家应对这些干扰不同的交互方式,以及为调和处理成功加一些验证技能,让玩家觉得调和成功不论是短期还是长期都是对整场战斗有意义的。

    • 如果希望玩家做一件事情,记得多站在玩家角度思考,玩家是否有必要这么做,是奖励足够吸引人,还是惩罚足够严重,玩家是否明确的知道奖励和惩罚。

    问题二:BOSS的节奏和时间轴设计

    • 提升方法:

    • 在狂暴前需要留出一段非机制时间,不然通常情况下狂暴前的阶段可能会被压掉(楚余音的风阶段)。

    • 要考虑到机制和机制之间的衔接以及配合,一个技能的伤害和范围以及密度不仅仅取决于它本身,还要看一起放的和前后放的是什么技能,例如设计过程中出现了让玩家先远离BOSS,然后BOSS又放了一个近身AOE的情况,那其实这个AOE就是白放,根本打不到人。

    问题三:基于设计文档的落地

    • 提升方法:

    • 要把自己放在BOSS负责人的角度,为这个BOSS整体负责。

    • 要多想,不要就先沉迷施工,多思考这个BOSS整体战斗起来的体验是什么样子的,预计的体验是什么样子的,多脑测,多拉着别人脑测。

    • 提完需求需要让有经验的同事看一下,因为初入职场可能对需求的描述以及各种点不熟悉。

    需求提出及落地

    问题四:提需求

    • 提升方法:

    • 要将对方当成不玩游戏的小白,同时不要带一些BOSS的专有名词(例如在提地板特效的时候提到“琴动”,可能对方只是做特效,根本不知道你BOSS咋打的也不关心),描述尽可能详细,包含范围,冲击力,饱和度,特效强度,同时尽可能找一些贴合的参考(多看几遍逆水寒已有boss的视频,对引擎内已有的特效效果有数,可以节省很多时间)。

    • 提美术需求时,需要站在玩家的角度去思考玩家是怎么看这个动作或者特效的,具有不同功能的特效需要样式有区分。

    • 尽量让特效在制作过程中多发中间版本效果,同时要将这些效果及时在策划群中进行同步,以免自己对特效的整体把握不准。

    问题五:避免返工

    • 提升方法:

    • 写文档前想清楚,为什么这个需求得是这样的,有什么设计目的。需求实现过程中要坚持,因为是从玩法和体验出发踢出去的需求,不符合的话会影响玩家对玩法的认知,不符合我的设计目的,要有理有据的说服。同时文档要写清楚,避免扯皮。

    • 对于自己不满意的效果,一定要提出来,不然后续就是更大的返工。

    • 对于自己没把握的效果,把副本负责人拉进群里一起对接。

    问题六:交互问题

    • 提升方法:

    • 新增技能时候,要考虑到技能的按下表现以及处理机制成功的表现,反馈是否即时。

    • 新增技能时,要考虑技能是否有动作,和其他技能同时释放会怎样,会不会打断一键连招等等。

    • 新增界面的需求时,要考虑到手机端用户的体验,能否看得清,按起来是否会不舒服。

    BOSS施工落地

    问题七:问题发现较晚

    • 改进方法:

    • 先梳理BOSS整体排期,拆分成阶段任务。

    • 每周需要和副本负责人对齐周进度,以及整体的体验上的方向。

    • 通流程比较重要,对工具和游戏内的动作和特效资源熟悉后,可以先用类似的效果去验证玩法,而不是等美术返回资源制作。

    问题九:迭代过程偏离最初设想

    • 改进方法:

    • 自己是最了解自己BOSS设计的人,要对自己设计的BOSS负责,其他人包括QA的建议都是他们的思考和想法,具体合不合适符不符合设计目的要自己考量。

    • 遇到体验问题和反馈时,一定要搞清楚问题的本质是什么,例如有玩家反馈抽卡的体验不好,最直接的办法是之前80抽一个,现在10抽一个,看起来是解决了,但其实不符合这个系统的设计目的(鼓励玩家花钱或者花时间游玩别的系统获得相关资源)。一定要在核心体验的基础上修改,往这个上面靠,如果是核心体验的问题,那就再返回去重新设计。

    ]]>
    @@ -258,7 +258,7 @@ https://blog.bingyan.net/posts/4aaa2c19/ 2024-10-23T10:59:00.000Z - 2025-02-14T09:06:53.555Z + 2025-02-14T09:20:46.758Z 苹果官方介绍了vision os用户界面设计原则,主要包括界面基础元素布局、app图标设计规范和材质选择,以加强空间视觉效果和用户体验。本文便适当拓展,从vision os的设计原则及其原理两个角度出发进行分享。

    一、空间界面设计原则

    • 图标规范

    这种图标需要使用多个图层,在其他平台实现这种视差效果需要在图片中处理,而在这里可以直接使用不带投影和高光的扁平图层,系统会自动实现这种真实的3D效果。

    在app中最多有三个图层:一个背景层和最多两个前景层,每层都是一个1024像素的矩形图片,并且当它们组合在一起时系统会自动添加一个类似玻璃的层,加强整体的深度、高光和阴影效果。同时尽量要保持图形居中,因为如果他们离边缘太近的话被聚焦时的突出效果看起来会偏离出去;并且避免用大面积的低不透明度效果,因为这样会和后面的阴影混到一起。

    • 材质

    app会在不同的背景环境中启动,因此他们应易于在用户周围的空间中显示,易于在任何距离、任何光线条件下使用,出于这个原因,苹果设计了一个新的视觉语言,即玻璃材质。它可以让用户周围的光线和虚拟内容的光线透过,
    让人感觉像现实世界的一部分,并且过多的不透明窗口会让人有压抑感,也会让界面看起来很重,玻璃材质又使它作为UI的一个画布显得更加轻盈。

    这种玻璃对光线的反应是动态的,通过调节对比度和色彩平衡能够与空间融为一体,就像这个从白天到夜晚的过渡。不同于IOS和Mac os,这个平台上没有明显的浅色或深色模式,利用这种特点可以让你的产品界面在各种场合和光线条件下都得到最好的呈现效果。

    在这种材质情况下尽量避免把浅色材质叠放到一起,这会降低易读性和对比度。

    • 字体

    首先为了保证一致性,所有的字体都使用了适用平台规范的point规范。为了提升易读性,苹果加粗了在前几层的字体,提高了对比度,例如在IOS正文样式使用的是regular,在这一平台上却使用了medium,标题使用了bold,让文本始终清晰可见。

    • 虚化效果

    在这一平台上,因为背景总是在变化的,所以虚化效果会动态变化以确保文本的易读性,苹果建议尽量使用系统组件,因为系统组件能完美提供这种虚化效果。如果关闭虚化效果,前景文字会明显变弱。虚化效果同时提高了易读性和材质外观。(左图为使用了虚化后的效果)

    • 颜色

    玻璃材质显示的是后面环境的颜色,所以大部分的时候建议用白色的字体或图标,因为彩色元素有可能会融入到背景中。如果元素需要使用颜色,可以将它设置为背景色或整个按钮的颜色,这样用户就能注意到它。并且尽量使用****系统颜色而非自定义颜色,因为系统色的易读性是经过测试且动态适配的。

    • 布局

    每一个人都是独一无二的,所以每个人的眼睛也许会有些差异,因此元素的大小应该容易被交互设计师注意到。苹果建议交互元素必须有至少60Pt****的交互判定大小,不过UI图标在视觉上可以更小,就像44Pt的按钮只需要在周围留白,加上一些可交互的范围。如果将这样几个按钮放在一排的话用16Pt的间距就可以。
    所以这种视觉能感知到的极端情况下,满足基本可交互的大小,原则是永远要给所有交互元素至少留60pt。

    如果要设计一个内容组的时候,确保组块的圆角与内部元素的圆角是同心圆,有一个简单的公式:内部圆角加上组块的空白间距等于组块的圆角。并且为了使圆角看起来更圆滑,需要设计成苹果的平滑圆角,保持这种嵌套元素之间都是同心的,这样他们会有一体感。

    二、空间动效原则

    • 视觉深度提示设计

    你想要呈现的深度和用户实际感知到的深度一致性,对于视觉上的舒适度是非常重要的。为了让视线正确聚焦,大脑需要一个正确的视觉深度提示, 当这种深度线索缺失相互冲突或有误导性时,用户可能会感到不适。

    如果有两块柠檬,其中较大的一块会让用户感觉离得更近。但是在这里相对大小的视觉提示与遮挡的视觉提示有冲突,这样会导致大脑产生预期相反的感觉。冲突越多,对于视觉舒适度的挑战也越大,所以需要确保所有的深度提示都能够为用户的大脑提供正确的信息。

    • 内容参数设计

    在各种特定的视觉体验时有不同的具体参数,建议选择让视觉减负的合适参数。有些场景需要眼睛长时间注释内容,例如阅读,当内容放置在比一臂距离更远时视觉上最舒适,并且要能够让用户自己调整属于自己的合适深度。同时可以使用视觉深度提示例如透明度或模糊效果让眼睛能够舒适地看远处的内容而不被干扰到。

    • 针对人眼的使用设计

    因为人的眼睛上下或左右转动是相对最舒服的选择,有一些位置通过视线去看时会让人感到疲劳,苹果建议需要长时间注视或阅读的内容应该放置在中心略低于视线的位置,这样可以提供一个舒适的视觉体验,如果你涉及的内容在一些特殊的位置需要稍微费力地去看,苹果建议设计一个简洁的交互或者直接让它们移动到视野中心

    • 虚拟对象的动效设计

    当视觉运动信息缺失或与前庭信息冲突时,人们可能会感到头晕或者胃部不舒服。例如当一个或像这种多个蓝色的虚拟物体在大部分视野中移动时,用户的大脑可能会有错觉像是自己在移动一样,在这种情况下稳定感会被扰乱,并且可能会导致对运动感的错误判断。作为设计师可以通过让对象在移动时变成半透明来缓解,这样运动的信息就会清晰可见。

    • 与头部固定的内容

    苹果建议尽量避免通过头部去固定内容。头部固定的内容会始终在用户的视野中间,容易影响方向和运动的判断。

    如果很需要这种头部固定的内容,建议使用较小的窗口尺寸,可以放在视线中心的附近并稍远一些的位置。最好的办法是使用内容锁定在周围环境中的视图或使用延迟跟随的动画,例如内容在一小段时间后缓慢地向目标位置移动。

    • 窗口内的动效

    当窗口里面的视频内容有移动时用户的大脑也可能认为是自己在移动。为了提供最佳的空间运动舒适度,首先需要保证窗口中内容的地平线与真实地平线对齐,所有视觉上的内容看起来都来自消失点,因为人们已经习惯了这一点。以这样的方式移动相机可以让视觉上的消失点比较缓和并且能够预测。

    并且需要将消失点保持在视野范围内,这意味着要避免快速转向或单纯的旋转。作为快速旋转的替代方案,可以在快速淡出的效果下让方向瞬间切换。

    为了优化运动舒适度,还需要避免近距离显示大型物体,最好让物体保持较小并保持较大的距离。

    ]]>
    @@ -283,7 +283,7 @@ https://blog.bingyan.net/posts/dcf1d36/ 2024-10-22T10:25:00.000Z - 2025-02-14T09:06:53.571Z + 2025-02-14T09:20:46.797Z 我们在使用 Redis 时,
    不可避免地会遇到并发访问的问题

    比如说如果多个用户同时下单,就会对缓存在 Redis 中的商品库存并发更新
    。一旦有了并发写操作,数据就会被修改,
    如果我们没有对并发写请求做好控制,就可能导致数据被改错,影响到业务的正常使用(例如库存数据错误,导致下单异常)

    为了保证并发访问的正确性,Redis 提供了两种方法,分别是
    加锁和原子操作

    加锁是一种常用的方法,在读取数据前,客户端需要先获得锁,否则就无法进行操作。当一个客户端获得锁后,就会一直持有这把锁,直到客户端完成数据更新,才释放这把锁。

    看上去好像是一种很好的方案,但是,其实这里会有两个问题:

    • 一个是,
      如果加锁操作多,会降低系统的并发访问性能;

    • 第二个是,
      Redis 客户端要加锁时,需要用到分布式锁,而分布式锁实现复杂,需要用额外的存储系统来提供加解锁操作。

    原子操作是另一种提供并发访问控制的方法

    原子操作是指执行过程保持原子性的操作,而且原子操作执行时并不需要再加锁,实现了无锁操作。这样一来,既能保证并发控制,还能减少对系统并发性能的影响

    原子操作的目标是实现并发访问控制,那么当有并发访问请求时,我们具体需要控制什么呢?接下来,我就先向你介绍下并发控制的内容。

    并发访问中需要对什么进行控制?

    我们说的并发访问控制,
    是指对多个客户端访问操作同一份数据的过程进行控制,以保证任何一个客户端发送的操作在 Redis 实例上执行时具有互斥性
    。例如,客户端 A 的访问操作在执行时,客户端 B 的操作不能执行,需要等到 A 的操作结束后,才能执行。

    并发访问控制对应的操作
    主要是数据修改操作
    。当客户端需要修改数据时,基本流程分成两步:

    • 客户端先把数据读取到本地,在本地进行修改;
    • 客户端修改完数据后,再写回 Redis。

    我们把这个流程叫做
    “读取 - 修改 - 写回”操作(Read-Modify-Write,简称为 RMW 操作)
    。当有多个客户端对同一份数据执行 RMW 操作的话,
    我们就需要让 RMW 操作涉及的代码以原子性方式执行。访问同一份数据的 RMW 操作代码,就叫做临界区代码。

    不过,当有多个客户端并发执行临界区代码时,就会存在一些潜在问题,接下来,我用一个多客户端更新商品库存的例子来解释一下。

    我们先看下临界区代码。假设客户端要对商品库存执行扣减 1 的操作,伪代码如下所示:

    1
    2
    3
    4
    5
    current = GET(id)

    current--

    SET(id, current)

    可以看到,客户端首先会根据商品 id,从 Redis 中读取商品当前的库存值 current(对应 Read),然后,客户端对库存值减 1(对应 Modify),再把库存值写回 Redis(对应 Write)。当有多个客户端执行这段代码时,这就是一份临界区代码。

    如果我们对临界区代码的执行没有控制机制,就会出现数据更新错误。在刚才的例子中,假设现在有两个客户端 A 和 B,同时执行刚才的临界区代码,就会出现错误,你可以看下下面这张图。

    可以看到,客户端 A 在 t1 时读取库存值 10 并扣减 1,在 t2 时,客户端 A 还没有把扣减后的库存值 9 写回 Redis,而在此时,客户端 B 读到库存值 10,也扣减了 1,B 记录的库存值也为 9 了。等到 t3 时,A 往 Redis 写回了库存值 9,而到 t4 时,B 也写回了库存值 9。

    如果按正确的逻辑处理,
    客户端 A 和 B 对库存值各做了一次扣减,库存值应该为 8。所以,这里的库存值明显更新错了。

    出现这个现象的原因是,
    临界区代码中的客户端读取数据、更新数据、再写回数据涉及了三个操作,而这三个操作在执行时并不具有互斥性,多个客户端基于相同的初始值进行修改,而不是基于前一个客户端修改后的值再修改

    为了保证数据并发修改的正确性,
    我们可以用锁把并行操作变成串行操作,串行操作就具有互斥性。一个客户端持有锁后,其他客户端只能等到锁释放,才能拿锁再进行修改。

    下面的伪代码显示了使用锁来控制临界区代码的执行情况。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    LOCK()

    current = GET(id)

    current--

    SET(id, current)

    UNLOCK()

    虽然加锁保证了互斥性,但是
    加锁也会导致系统并发性能降低

    如下图所示,
    当客户端 A 加锁执行操作时,客户端 B、C 就需要等待。A 释放锁后,假设 B 拿到锁,那么 C 还需要继续等待,所以,t1 时段内只有 A 能访问共享数据,t2 时段内只有 B 能访问共享数据,系统的并发性能当然就下降了

    和加锁类似,
    原子操作也能实现并发控制,但是原子操作对系统并发性能的影响较小,接下来,我们就来了解下 Redis 中的原子操作。

    Redis 的两种原子操作方法

    为了实现并发控制要求的临界区代码互斥执行,Redis 的原子操作采用了两种方法:

    • 把多个操作在
      Redis 中实现成一个操作,也就是单命令操作;

    • 把多个操作
      写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本。

    我们先来看下 Redis 本身的单命令操作。

    Redis 是使用单线程来串行处理客户端的请求操作命令的,所以,当 Redis 执行某个命令操作时,其他命令是无法执行的,这相当于命令操作是互斥执行的
    。当然,
    Redis 的快照生成、AOF 重写这些操作,可以使用后台线程或者是子进程执行,也就是和主线程的操作并行执行
    。不过,
    这些操作只是读取数据,不会修改数据,所以,我们并不需要对它们做并发控制

    你可能也注意到了,
    虽然 Redis 的单个命令操作可以原子性地执行,但是在实际应用中,数据修改时可能包含多个操作,至少包括读数据、数据增减、写回数据三个操作,这显然就不是单个命令操作了,那该怎么办呢

    别担心,
    Redis 提供了 INCR/DECR 命令,把这三个操作转变为一个原子操作了。INCR/DECR 命令可以对数据进行增值 / 减值操作,而且它们本身就是单个命令操作,Redis 在执行它们时,本身就具有互斥性

    比如说,在刚才的库存扣减例子中,
    客户端可以使用下面的代码,直接完成对商品 id 的库存值减 1 操作。即使有多个客户端执行下面的代码,也不用担心出现库存值扣减错误的问题

    1
    DECR id

    所以,
    如果我们执行的 RMW 操作是对数据进行增减值的话,Redis 提供的原子操作 INCR 和 DECR 可以直接帮助我们进行并发控制

    但是,如果我们要执行的操作不是简单地增减数据,
    而是有更加复杂的判断逻辑或者是其他操作,那么,Redis 的单命令操作已经无法保证多个操作的互斥执行了。所以,这个时候,我们需要使用第二个方法,也就是 Lua 脚本

    Redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。如果我们有多个操作要执行,但是又无法用 INCR/DECR 这种命令操作来实现,就可以把这些要执行的操作编写到一个 Lua 脚本中
    。然后,
    我们可以使用 Redis 的 EVAL 命令来执行脚本。这样一来,这些操作在执行时就具有了互斥性

    我再给你举个例子,来具体解释下 Lua 的使用。

    当一个业务应用的访问用户增加时,我们有时需要限制某个客户端在一定时间范围内的访问次数,比如爆款商品的购买限流、社交网络中的每分钟点赞次数限制等。

    那该怎么限制呢?
    我们可以把客户端 IP 作为 key,把客户端的访问次数作为 value,保存到 Redis 中。客户端每访问一次后,我们就用 INCR 增加访问次数

    不过,在这种场景下,
    客户端限流其实同时包含了对访问次数和时间范围的限制,例如每分钟的访问次数不能超过 20。所以,我们可以在客户端第一次访问时,给对应键值对设置过期时间,例如设置为 60s 后过期。同时,在客户端每次访问时,我们读取客户端当前的访问次数,如果次数超过阈值,就报错,限制客户端再次访问。你可以看下下面的这段代码,它实现了对客户端每分钟访问次数不超过 20 次的限制。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    //获取ip对应的访问次数

    current = GET(ip)

    //如果超过访问次数超过20次,则报错

    IF current != NULL AND current > 20 THEN

    ERROR "exceed 20 accesses per second"

    ELSE

    //如果访问次数不足20次,增加一次访问计数

    value = INCR(ip)

    //如果是第一次访问,将键值对的过期时间设置为60s后

    IF value == 1 THEN

    EXPIRE(ip,60)

    END

    //执行其他操作

    DO THINGS

    END

    对于这
    可以看到,在这个例子中,我们已经使用了 INCR 来原子性地增加计数。但是,客户端限流的逻辑不只有计数,还包括访问次数判断和过期时间设置

    对于这些操作,
    我们同样需要保证它们的原子性。否则,如果客户端使用多线程访问,访问次数初始值为 0,第一个线程执行了 INCR(ip) 操作后,第二个线程紧接着也执行了 INCR(ip),此时,ip 对应的访问次数就被增加到了 2,我们就无法再对这个 ip 设置过期时间了。这样就会导致,这个 ip 对应的客户端访问次数达到 20 次之后,就无法再进行访问了。即使过了 60s,也不能再继续访问,显然不符合业务要求。

    所以,这个例子中的操作无法用 Redis 单个命令来实现,
    此时,我们就可以使用 Lua 脚本来保证并发控制。我们可以把访问次数加 1、判断访问次数是否为 1,以及设置过期时间这三个操作写入一个 Lua 脚本,如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    local current

    current = redis.call("incr",KEYS[1])

    if tonumber(current) == 1 then

    redis.call("expire",KEYS[1],60)

    end

    假设我们编写的脚本名称为 lua.script,我们接着就可以使用 Redis 客户端,带上 eval 选项,来执行该脚本。
    脚本所需的参数将通过以下命令中的 keys 和 args 进行传递。

    1
    redis-cli --eval lua.script keys , args

    这样一来,访问次数加 1、判断访问次数是否为 1,以及设置过期时间这三个操作就可以原子性地执行了。
    即使客户端有多个线程同时执行这个脚本,Redis 也会依次串行执行脚本代码,避免了并发操作带来的数据错误。

    ]]>
    @@ -307,7 +307,7 @@ https://blog.bingyan.net/posts/965ff9c0/ 2024-10-20T08:08:00.000Z - 2025-02-14T09:06:53.556Z + 2025-02-14T09:20:46.758Z

    在开发浏览器插件,或者写油猴脚本的时候,需要拦截一些 ws/http 请求。
    本文来简单总结一下用过的实践。

    在 Chrome Extensions 中拦截请求

    在插件中,可以通过 chrome.webRequest API 非常便捷地实现拦截。

    webRequest - MDN

    在 Manifest V3 平台下,屏蔽网络请求/重定向多个网址等需求无法再用 webRequest API。
    See developer.chrome.com - 替换屏蔽型 Web 请求监听器

    1
    2
    3
    4
    5
    6
    // manifest.json
    "permissions": [
      "webRequest",
      // ...
    ],

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // content.js
    chrome.webRequest.onBeforeRequest.addListener(
      details => {
        console.log(details.url)
      },
      { urls: ["<all_urls>"] }
    )

    chrome.webRequest.onCompleted.addListener(
      details => {
        console.log(details)
      },
      { urls: ["<all_urls>"] }
    )

    有关 details 对象,see onResponseStarted#details_2 - MDN

    这一 API 可以轻松的获取请求头/请求体/响应头等信息。但是其无法获取到 response body,所以无法获得或修改响应结果。这就对需要 hook 到详细内容的功能不友好。

    (这里不讨论用 devtools API 的方法,感觉不太通用)

    考虑脚本注入

    脚本注入即是将一段 JS 插入到页面某一位置中执行。可以通过事件系统 + 修改 window.XMLHttpRequest/fetch/WebSocket 对象的方式实现 hooks。

    拦截 XHR 请求

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    // 注入
    ;(function () {
      function ajaxEventTrigger (event) {
        const ajaxEvent = new CustomEvent(event, { detailthis })
        window.dispatchEvent(ajaxEvent)
      }

      const OldXHR = window.XMLHttpRequest

      function NewXHR () {
        const realXHR = new OldXHR()

        realXHR.addEventListener('readystatechange'function () { ajaxEventTrigger.call(this'ajaxReadyStateChange') }, false)
        // ...
        // abort error load loadstart progress timeout loadend 事件同理
      
        const setRequestHeader = realXHR.setRequestHeader
        realXHR.requestHeader = {}
        realXHR.setRequestHeader = function (name, value) {
          realXHR.requestHeader[name] = value
          setRequestHeader.call(realXHR, name, value)
        }
        return realXHR
      }

      window.XMLHttpRequest = NewXHR
    })()

    1
    2
    3
    4
    5
    6
    7
    8
    // 使用
    window.addEventListener('ajaxReadyStateChange', e => {
      const xhr = e.detail
      if (xhr.readyState === 4 && xhr.status === 200) {
        handleResponse(xhr)
      }
    })

    拦截 fetch 请求

    fetch API 基于 Promise,因此拦截请求不必引入事件系统。这里给出一种方案:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    fetch = (...args) => {
      return Promise.resolve(args)
        .then(args => beforeHook(...args))
        .then(args => {
          const request = new Request(...args)
          return oldFetch(request)
        })
        .then(resp => afterHook(resp))
    }

    const beforeHook = () => { /* ... */ }
    const afterHook = () => { /* ... */ }

    See github.com/mlegenhausen/fetch-intercept

    拦截 ws 请求

    这里给出一个简化版的 ws hook。思路很简单,在原生 WS 的事件中插入一个钩子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    function MutableMessageEvent (o) {
      this.bubbles = o.bubbles || false
      this.cancelBubble = o.cancelBubble || false
      this.cancelable = o.cancelable || false
      this.currentTarget = o.currentTarget || null
      this.data = o.data || null
      this.defaultPrevented = o.defaultPrevented || false
      this.eventPhase = o.eventPhase || 0
      this.lastEventId = o.lastEventId || ''
      this.origin = o.origin || ''
      this.path = o.path || new Array(0)
      this.ports = o.parts || new Array(0)
      this.returnValue = o.returnValue || true
      this.source = o.source || null
      this.srcElement = o.srcElement || null
      this.target = o.target || null
      this.timeStamp = o.timeStamp || null
      this.type = o.type || 'message'
      this.__proto__ = o.__proto__ || MessageEvent.__proto__
    }

    const wsHook = {
      before: data => data,
      after: e => e
    }

    const _WS = WebSocket
    WebSocket = function (url) {
      this.url = url
      const WSObject = new _WS(url)

      const _send = WSObject.send
      WSObject.send = function (data) {
        arguments[0] = wsHook.before(data) || data
        _send.apply(this, arguments)
      }

      WSObject._addEventListener = WSObject.addEventListener
      WSObject.addEventListener = function () {
        const eventThis = this
        // if eventName is 'message'
        if (arguments[0] === 'message') {
          arguments[1] = (function (userFunc) {
            return function instrumentAddEventListener () {
              arguments[0] = wsHook.after(new MutableMessageEvent(arguments[0]))
              if (arguments[0] === nullreturn
              userFunc.apply(eventThis, arguments)
            }
          })(arguments[1])
        }
        return WSObject._addEventListener.apply(this, arguments)
      }

      return WSObject
    }

    1
    2
    3
    4
    5
    6
    7
    8
    // 使用
    wsHook.before = (data) => {
      console.log(data)
    }
    wsHook.after = (messageEvent) => {
      console.log(messageEvents)
    }

    在浏览器拓展中注入 hooks

    由于在浏览器拓展 content_scripts 环境与真实的页面环境隔离,只能获取 DOM 等,无法直接更改 window.XMLHttpRequest/fetch/WebSocket 对象。所以必须借助 script 标签注入。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // manifest.json
    {
      "content_scripts": [
        {
          "matches": ["http://*/*""https://*/*"],
          "js": ["content-script.js"]
        }
      ],
      "web_accessible_resources": [
        {
          "resources": ["injected-script.js"],
          "matches": ["http://*/*""https://*/*"]
        }
      ]
    }

    1
    2
    3
    4
    5
    // content-script.js
    const scriptDOM = document.createElement('script')
    scriptDOM.setAttribute('src''injected-script.js')
    document.head.appendChild(s)

    1
    2
    3
    4
    5
    6
    7
    8
    // injected-script.js

    // 修改 XHR 对象
    window.XMLHttpRequest = newXHR

    // 监听
    window.addEventListener('ajaxReadyStateChange', handleFunc)

    参考

    https://github.com/debingfeng/blog/blob/master/docs/javascript/practise/JavaScript%E7%9B%91%E5%90%AC%E6%89%80%E6%9C%89Ajax%E8%AF%B7%E6%B1%82%E7%9A%84%E4%BA%8B%E4%BB%B6.md

    https://github.com/mlegenhausen/fetch-intercept

    https://github.com/skepticfx/wshook

    ]]>
    @@ -331,7 +331,7 @@ https://blog.bingyan.net/posts/3e5c772e/ 2024-10-16T13:30:00.000Z - 2025-02-14T09:06:53.572Z + 2025-02-14T09:20:46.798Z 留存率是衡量产品成功的关键指标之一,它反映了用户在首次使用产品后继续回来使用的比例。本文将从定义、计算方法和应用场景三个方面,对用户留存进行详细解析。

    一、用户留存的定义

    用户留存通常指的是用户在首次使用产品后,在特定时间周期内再次使用产品的比例。在用户行为分析中,留存可以更精确地定义为:在第一个时间周期内发生了起始事件的用户,在第二个时间周期内发生回访事件的比率。


    计算公式

    留存率=(第二个时间周期内发生回访事件的用户数第一个时间周期内发生了起始事件的用户数)×100%留存率=(第一个时间周期内发生了起始事件的用户数第二个时间周期内发生回访事件的用户数)×100%

    这个比率可以帮助我们了解用户对产品的粘性和忠诚度。高留存率通常意味着用户对产品满意,而低留存率可能表明用户在使用产品后没有找到足够的价值。

    二、留存的计算方法

    留存分析的核心在于观察触发起始事件的用户中,有多少人发生了回访事件

    1. 日留存计算

    以“日”为单位,计算特定日的次日、3日、7日或n日留存。例如,某一天的次日留存计算公式为:

    次日留存=(第1日发生回访事件的用户数第0日发生起始事件的用户数)×100%次日留存=(第0日发生起始事件的用户数第1日发生回访事件的用户数)×100%

    这种计算方式可以帮助我们了解用户在首次使用产品后的短期行为,从而评估产品的吸引力和用户满意度。

    2. 周/月留存计算

    以“周”或“月”为单位,计算特定周或月的n周或n月留存。例如,某一周的第n周留存计算公式为:

    第n周留存=(第n周触发过“回访事件”的用户数第0周触发“起始事件”的用户数)×100%第n周留存=(第0周触发“起始事件”的用户数第n周触发过“回访事件”的用户数)×100%

    这种计算方式有助于我们了解用户在较长时间周期内的行为模式,从而评估产品的长期吸引力。

    三、留存分析的应用场景

    1. 渠道质量评估

    通过日留存分析,可以评估不同渠道的用户表现,以此作为衡量渠道质量的标准之一。例如,如果某个渠道的用户的次日留存率显著高于其他渠道,那么这个渠道可能更有效地吸引了目标用户。

    2. 运营手段/功能改动效果评估

    通过比较覆盖到的新用户和未覆盖到的新用户的留存率,可以验证运营手段或功能改动的有效性。例如,如果一个新功能推出后,使用该功能的用户群体的留存率有所提高,那么这个新功能可能对用户留存有积极影响。

    3. 产品健康度衡量

    使用周留存、月留存等指标,可以观察用户在平台上的粘性,从而衡量产品的健康程度。如果一个产品的周留存或月留存持续下降,那么可能需要考虑产品改进或市场策略调整。

    四、分析方式

    1. 留存曲线分析

    留存曲线可以帮助我们了解用户在不同时间点的留存情况,从而发现用户流失的关键节点。通过分析留存曲线,我们可以识别出用户流失的高峰期,并采取措施来减少这些流失。

    2. 群组分析

    对不同用户群体(如新用户、老用户、不同渠道用户等)进行留存分析,可以发现不同群体的留存差异,为产品优化提供方向。例如,如果发现某个特定渠道的用户留存率较低,那么可能需要针对这个渠道的用户制定更有效的留存策略。

    3. 留存与用户生命周期价值(LTV)综合分析

    留存率与用户的生命周期价值(LTV)密切相关。高留存率通常意味着用户对产品的满意度高,可能会带来更高的LTV。因此,提高留存率也是提高产品收入的重要途径。

    用户留存是衡量产品成功的重要指标,通过深入分析留存数据,我们可以更好地理解用户行为,优化产品,并提高用户满意度。在实际工作中,应结合产品特性和业务需求,灵活运用留存分析方法。同时,留存分析也应该与其他用户行为分析工具相结合,如转化率分析、用户路径分析等,以获得更全面的用户洞察。

    ]]>
    @@ -357,7 +357,7 @@ https://blog.bingyan.net/posts/db6a5a72/ 2024-09-25T13:00:00.000Z - 2025-02-14T09:06:53.572Z + 2025-02-14T09:20:46.798Z 限流的思想

    在保证可用的情况下尽可能多增加进入的人数,其余的人在排队等待,或者返回友好提示,保证里面的进行系统的用户可以正常使用,防止系统雪崩。

    比如,在校园里面体测的时候,每次只放一部分的人进去,保证里面的人可以顺利完成体测,体现可用性,同时还有很多在外面等待的同学,这体现了限流。

    那么在日常生活中,还有哪些需要限流的地方呢?

    比如有一个国家景区,平时可能根本没什么人前往,但是一到五一或者春节就人满为患,这时候景区管理人员就会实行一系列的政策来限制进入人流量,那么为什么要限流呢?

    假如景区能容纳一万人,现在进去了三万人,势必摩肩接踵,整不好还会有事故发生,这样的结果就是所有人的体验都不好,如果发生了事故景区可能还要关闭,导致对外不可用,这样的后果就是所有人都觉得体验糟糕透了。

    限流的算法

    限流算法很多,常见的有三类,分别是计数器算法、漏桶算法、令牌桶算法,下面逐一讲解。

    (1)计数器:

    在一段时间间隔内(时间窗/时间区间),处理请求的最大数量固定,超过部分不做处理。

    (2)漏桶:

    漏桶大小固定,处理速度固定,但请求进入速度不固定(在突发情况请求过多时,会丢弃过多的请求)。

    (3)令牌桶:

    令牌桶的大小固定,令牌的产生速度固定,但是消耗令牌(即请求)速度不固定(可以应对一些某些时间请求过多的情况);每个请求都会从令牌桶中取出令牌,如果没有令牌则丢弃该次请求。

    计数器算法

    计数器限流定义

    在一段时间间隔内(时间窗/时间区间),处理请求的最大数量固定,超过部分不做处理。

    简单粗暴,比如指定线程池大小,指定数据库连接池大小、nginx 连接数等,这都属于计数器算法。

    计数器算法是限流算法里最简单也是最容易实现的一种算法。

    举个例子,比如我们规定对于 A 接口,我们 1 分钟的访问次数不能超过 100 个。

    那么我们可以这么做:

    在一开始的时候,我们可以设置一个计数器 counter,每当一个请求过来的时候,counter 就加 1,如果 counter 的值大于 100 并且该请求与第一个请求的间隔时间还在 1 分钟之内,那么说明请求数过多,拒绝访问;

    如果过了这个时间戳,那么就重置 counter,就是这么简单粗暴。

    漏桶算法

    漏桶算法限流的基本原理为:水(对应请求)从进水口进入到漏桶里,漏桶以一定的速度出水(请求放行),当水流入速度过大,桶内的总水量大于桶容量会直接溢出,请求被拒绝。

    大致的漏桶限流规则如下:

    • 进水口(对应客户端请求)以任意速率流入进入漏桶。
    • 漏桶的容量是固定的,出水(放行)速率也是固定的。
    • 漏桶容量是不变的,如果处理速度太慢,桶内水量会超出了桶的容量,则后面流入的水滴会溢出,表示请求拒绝。

    漏桶算法原理

    漏桶算法思路很简单:

    水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会超过桶可接纳的容量时直接溢出。

    可以看出漏桶算法能强行限制数据的传输速率。

    漏桶算法其实很简单,可以粗略的认为就是注水漏水过程,往桶中以任意速率流入水,以一定速率流出水,当水超过桶容量则丢弃,因为桶容量是不变的,保证了整体的速率。

    算法特点:

    • 削峰:有大量流量进入时,会发生溢出,从而限流,保护服务可用。
    • 缓冲:不至于大量的请求直接请求到服务器,而是先过滤一下,缓冲压力。

    漏桶算法通过限制流量的入口速率,能有效应对突发流量,将突发流量平滑化,起到平滑突发流量(整流)的作用,以防止资源被瞬间耗尽。

    令牌桶限流

    令牌桶算法以一个设定的速率产生令牌并放入令牌桶,每次用户请求都得申请令牌,如果令牌不足,则拒绝请求。

    令牌桶算法中新请求到来时会从桶里拿走一个令牌,如果桶内没有令牌可拿,就拒绝服务。当然,令牌的数量也是有上限的。令牌的数量与时间和发放速率强相关,时间流逝的时间越长,会不断往桶里加入越多的令牌,如果令牌发放的速度比申请速度快,令牌桶会放满令牌,直到令牌占满整个令牌桶。

    令牌桶限流大致的规则如下:

    • 进水口按照某个速度,向桶中放入令牌。
    • 令牌桶的容量是固定的,但是放行的速度不是固定的,只要桶中还有剩余令牌,一旦请求过来就能申请成功,然后放行。
    • 如果令牌的发放速度,慢于请求到来速度,桶内就无牌可领,请求就会被拒绝。

    总之,令牌的发送速率可以设置,从而可以对突发的出口流量进行有效的应对。

    令牌桶算法

    令牌桶与漏桶相似,不同的是令牌桶桶中放了一些令牌,服务请求到达后,要获取令牌之后才会得到服务,举个例子,我们平时去食堂吃饭,都是在食堂内窗口前排队的,这就好比是漏桶算法,大量的人员聚集在食堂内窗口外,以一定的速度享受服务,如果涌进来的人太多,食堂装不下了,可能就有一部分人站到食堂外了,这就没有享受到食堂的服务,称之为溢出,溢出可以继续请求,也就是继续排队,那么这样有什么问题呢?

    如果这时候有特殊情况,比如有些人赶时间,如果也用漏桶算法那也得慢慢排队,这也就没有解决我们的需求,对于很多应用场景来说,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。这时候漏桶算法可能就不合适了,令牌桶算法更为适合。

    令牌桶的好处

    令牌桶的好处之一就是可以方便地应对 突发出口流量(后端能力的提升)。

    比如,可以改变令牌的发放速度,算法能按照新的发送速率调大令牌的发放数量,使得出口突发流量能被处理。

    Nginx 漏桶限流

    Nginx 限流的简单配置如下:

    1
    limit_req_zone  $binary_remote_addr  zone=test:10m      rate=6r/s; #每一秒处理六次请求

    在 location 块中使用限流 zone,参考如下:

    1

    1
    2
    3
    4
    location  = / {
    limit_req zone=test;
    echo "正常的响应";
    }
    1

    Nginx 漏桶限流的三个细分类型,即 burst、nodelay 参数详解

    不带缓冲队列的漏桶限流

    仔细看的话,就会发现这个命名空间后面的:没有了,那个是用来设置缓冲空间大小的。

    1
    limit_req $binary_remote_addr zone=test rate=1r/s;
    1

    严格依照在 test 中配置的 rate 来处理请求,超过 rate 处理能力范围的,直接 drop,表现为对收到的请求无延时,假设 1 秒内提交 10 个请求,可以看到一共 10 个请求,9 个请求都失败了,直接返回 503。

    带缓冲队列的漏桶限流

    1
    limit_req $binary_remote_addr zone=test burst=5 rate=1r/s;
    1

    依照在 test 中配置的 rate 来处理请求,同时设置了一个大小为 5 的缓冲队列,在缓冲队列中的请求会等待慢慢处理,超过了 burst 缓冲队列长度和 rate 处理能力的请求被直接丢弃,表现为对收到的请求有延时。

    假设 1 秒内提交 10 个请求,则可以发现在 1s 内,在服务器接收到 10 个并发请求后,先处理 1 个请求,同时将 5 个请求放入 burst 缓冲队列中,等待处理。而超过(burst+1)数量的请求就被直接抛弃了,即直接抛弃了 4 个请求。burst 缓存的 5 个请求每隔 6s 处理一次。

    带瞬时处理能力的漏桶限流

    1
    limit_req $binary_remote_addr zone=test burst=5 rate=1r/s nodelay;

    如果设置 nodelay,会在瞬时提供处理(burst + rate)个请求的能力,请求数量超过(burst + rate)的时候就会直接返回 503,峰值范围内的请求,不存在请求需要等待的情况。

    简单的说就是,接受的请求处理的时候 nodelay,不接受的请求拒绝的时候也是 nodelay。

    假设 1 秒内提交 10 个请求,则可以发现在 1s 内,服务器端处理了 6 个请求(峰值速度:burst + 1s 内一个请求)。对于剩下的 4 个请求,直接返回 503,在下一秒如果继续向服务端发送 10 个请求,服务端会直接拒绝这 10 个请求并返回 503。

    总数额度要和速度时间保持一致, 就是额度用完了,需要等到一个有额度的时间段,才开始接收新的请求。如果一次处理了 5 个请求,相当于占了 5s 的额度。因为设定了 1s 处理 1 个请求,所以直到 5s 之后,才可以再处理一个请求,即如果此时向服务端发送 10 个请求,会返回 9 个 503,一个 200。

    尾声

    什么什么,还想知道更多关于 Nginx 的有趣小知识,那么不妨去官网看看吧。

    https://nginx.org/en/docs/

    什么什么,你觉得 Nginx 的功能不是很多,每次都要自己加模块太麻烦,那么不妨试试 OpenResty 吧,一个使用 Lua 脚本的增强版 Nginx,你还可以自己使用 Lua 脚本给 Nginx 来做二次开发,编写更多适合业务的小功能哦。

    https://openresty.org/cn/

    ]]>
    @@ -381,7 +381,7 @@ https://blog.bingyan.net/posts/faa823fe/ 2023-11-04T13:33:54.000Z - 2025-02-14T09:06:53.563Z + 2025-02-14T09:20:46.774Z 基于协程的异步编程入门

    大家好啊,我是说的异步。今天来给大家协程的东西啊。

    网络编程时,大家常用的实现并发的方式有多进程、多线程和 IO 复用几种。其中,多进程和多线程的做法都是在文件描述上做阻塞的读写操作,由操作系统来决定什么时候什么进程/线程可以执行,从而实现并发的服务。而 IO 复用则是利用 select poll kqueue epoll 等系统调用来监控文件描述符,仅在确定操作文件描述符时不会引起阻塞时才对其进行操作,从而实现并发的服务。

    几种实现并发的方式中,IO 复用的并发模型的资源消耗最小。然而,之前异步编程的模型并不是很成熟,由于在做 IO 复用的并发模型时,程序状态切换、中间变量等等都需要手动维护,写出来的程序结构相当复杂、控制流乱飞、而且处理同一件事情的逻辑分布在好几个地方。异步程序的实现非常依靠程序员的聪明才智。再加上互联网早期网站对并发量的要求并不是很高,异步编程的模型几乎不是主流选择。

    后来人们把协程、Reactor 模式和 IO 复用的并发模型结合,并且抽象出了运行时的概念来统一调度协程、对接事件循环,极大地简化了人们在编写异步程序时的心智负担。加入 async/await 语法后人们可以像写同步程序一样编写异步程序,相当简单。很多语言和框架现在都加入了异步和协程的特性,好时代,来临力。

    这篇文章将教你常见的协程结构,简易协程的运行时架构,以及各个部分是怎么协作的。但是考虑到篇幅,并不会教你如何实现它们。同时不会教大家 IO 复用是什么,因为大家操作系统还是别的什么课上肯定已经学过了,就是 select 和 epoll 那一套。也不会教大家协程怎么用,因为大家肯定已经用过了。

    协程的数据结构

    主流的协程分为两种,有栈协程和无栈协程。它们的内部结构,以及控制流转移的方式相当不同。

    有栈协程

    有栈协程的结构与我们平时用的线程的结构几乎一模一样。它会在自己数据结构中存放完整的上下文数据(在 Linux 下的 C 中,通常是由 ucontext 库创建的 ucontext_t 对象;在 Lua 中,它会保存一个完整的协程栈)。当需要发生控制流转换,比如协程自已调用 yield 函数,或者遇到了阻塞点时,会触发一次上下文的切换。这个上下文的切换是在用户空间完成的,以使用 ucontext 库实现的 C 协程为例,它在上下文切换时通常会使用 swapcontext() 函数,将下一个需要执行的协程的整个上下文与当前的上下文交换,接着开始执行。当前协程的寄存器和局部变量等都被保存下来,存放在 ucontext_t 对象中,等待下一次执行。

    可以看到,因为保存了完整的上下文信息,协程的使用体验和普通的线程相当一致。在有栈协程里,你可以递归调用函数,并且在任意一层函数的任意位置转移控制流。并且理论上你可以直接指定哪个协程下一个执行,不需要运行时的参与,虽然不会有人想这么做就是了。

    无栈协程

    要描述无栈协程实现方式是一件很困难的事情,因为各个语言中实现无栈协程的方式都略有不同。不过我们还是可以考察一些无栈协程之间的共同点。

    无栈协程的结构与有栈协程的结构相当不同。相比有栈协程的数据结构中会保存整个栈,无栈协程仅保存了一些状态参数和自己所用到的跨 yield 的局部变量。这也使得它的数据结构相比有栈协程的来说非常小巧。

    跨 yield 的局部变量:指协程交出控制权前与拿到控制权后都在使用的局部变量。

    无栈协程通常会用 Promise(JavaScript) 或者 Future(Rust) 之类的名字来指代它的数据结构。两者基本上可以认为是同一个东西,大家在平时编程时应该也用到过。这里不再详细介绍它的状态、使用方法之类的东西。

    一般来说,Promise(Future) 会支持一个名为 .then() 的成员方法,它接收一个匿名函数作为参数,在该 Promise fulfilled(ready)后以 fulfill 的结果作为参数调用该匿名函数。实际使用时,如果 .then() 函数内部存在较复杂的控制流,很容易使代码陷入函数套函数、Promise 套 Promise 的窘境。所以,实践时我们一般会用一种叫做 await 的语法糖来与 Promise 配合,相信大家或多或少也用过一些。await 接收一个 Promise 作为参数后,将当前协程的控制权交出,直到它参数的那个 Promise 状态不为 pending(即已执行完毕)后才继续执行后面的代码。

    虽然很多语言都有 await 语法糖,但是它们的实现并不相同。这是语言本身的特性决定的。

    考虑这段 JavaScript 代码(我不太会 js,有错误的话请告诉我一下):

    1
    2
    3
    4
    var x = await some_promise1(1)
    var y = await some_promise2(x)
    var z = await some_promise3(y)
    ... 别的代码

    如果让我们自己来决定 await 语法糖应该如何变换代码,最容易想到的也最通用的实现模式是以 await 调用作为分界点对代码做 CPS 变换:

    1
    2
    3
    4
    5
    6
    7
    some_promise(1).then((x) => {
    some_promise2(x).then((y) => {
    some_promise3(y).then((z) => {
    ... 别的代码
    })
    })
    })

    它利用了我们已经有的 Promise 类型的 .then() 函数基础设施,看起来和语言非常统一且优雅。但是实际上根本没有语言是这么实现 await 操作的。各个语言的 Promise 都有各自内部的神秘魔法。Javascript 和 Python 在内部使用了自己的 generator 机制实现了 await 操作;而 Rust 选择将整个 async 代码块编译成状态机,以 await 作为分隔点把各个部分的代码放到状态机的各处;Perl 比较逆天,解释器不支持语法糖就运行时改符号表并配合 C 语言扩展自己实现了一个。

    相比有栈协程较为统一的 “在换出时存下所有东西,换入时复制回来” 的做法,无栈协程的实现算得上是八仙过海各显神通,需要与语言特性以及编译器/解释器紧密配合。所以别再说什么 “无栈协程不就是个 generator/状态机/CPS 变换后的函数” 啦!

    有栈协程的局限性

    有栈协程的结构十分接近正常的线程,它几乎没有什么局限性。能想到的也就是:

    • 性能可能比无栈协程低
    • 会受到栈溢出问题影响

    但是实现了有栈协程的语言一般都会自己配上一个栈扩容机制(比如 Go),所以后者一般也不是什么问题。

    无栈协程的局限性

    无栈协程看起来像函数,实际上不是函数。在编译器/解释器和运行时的共同努力下,无栈协程用起来大部分时候和普通线程差不多,但是仍在少部分情况下会有点不同。这些是由无栈协程本身结构决定的。下面列举两个:

    嵌套同步函数中转移控制权

    无栈协程会在什么时候转移控制权呢?答案是在遇到 await 的时候。而所有的 await 在你写代码的时候都已经确定了,已经变成 generator/状态机/CPS 变换后的函数/其他什么奇怪东西 了。也就是说,无法做出一个高阶的异步函数。考虑下面这段代码:

    1
    2
    3
    4
    var numbers = [4, 9, 16, 25];
    numbers.forEach((x) => {
    await send_number_over_internet(x)
    })

    这段代码遍历 numbers 并且尝试将其通过互联网依次发送。这段代码是不能运行的,因为这里用到的闭包是一个同步函数,内部无法用 await 来转移控制权。其他同步函数也是同理,一旦调用同步函数,就只能等它执行完毕。

    有栈协程因为在上下文切换时会保留完整的函数调用栈,所以允许从在任意深度的函数调用中将控制权转移出来。

    抢占式调度

    著名编程语言 Go 中实现了抢占式的协程调度。以它为例。

    在旧版本的抢占式调度器中,Go 语言会在协程调用函数时进行协程调度。有栈协程中并不存在同步函数与异步函数的区别,这种抢占式调度实际上相当于在每个函数的入口插桩。

    可以发现,旧版本的抢占式调度器中,如果某个协程一直运行并且不调用任何函数,那么抢占式调度器将会失效。因此在新版本中,Go 用 sysmon 监控线程配合信号机制,实现全新的抢占式调度器。当 sysmon 线程发现某个协程执行时间过长时,就会向对应的线程发送操作系统信号,触发上下文的切换。

    sysmon 线程:始终在后台运行的监控线程,并不会执行 goroutine。

    而这两种抢占式的调度器均不能在无栈协程中实现。Go 旧版本的调度器需要协程具有在普通函数调用中挂起的能力,新版本的调度器需要协程具有在任意位置挂起的能力。这两种都是无栈协程所难以实现的。因此,目前并没有采用无栈协程的主流语言实现了抢占式的调度器。

    常见语言的实现选择

    • C:并没有官方实现。常见的协程库均为有栈协程。难用。
    • Rust:一般采用无栈协程,标准库仅提供 Future 机制但是并不提供运行时。好用。各个运行时的能力稍有区别。
    • Go:语言核心提供了有栈协程及运行时的支持,相当统一且好用。完全可以把 goroutine 当成线程来写。
    • Lua:有栈协程。只研究了一下是怎么实现的,并没有用它写过程序。
    • Python:greenlet 提供了有栈协程支持,asyncio 提供了无栈协程支持。
    • Javascript:提供了无栈协程的语法支持。还没太看懂实现方式,但是我感觉它的内部实现的数据结构可能是种类似有栈协程的,会保留整个协程栈的东西。
    • Perl:提供了有栈协程的支持。在此基础上有人通过运行时修改符号表的神奇操作提供了 Promise 的语法支持。
    ]]>
    diff --git a/categories/default/index.html b/categories/default/index.html index cfc6e40..996871f 100644 --- a/categories/default/index.html +++ b/categories/default/index.html @@ -132,7 +132,7 @@

    default

    - +
    diff --git a/index.html b/index.html index fca626a..c61cba7 100644 --- a/index.html +++ b/index.html @@ -132,7 +132,7 @@

    Latest Posts

    - +