大家好! 我叫Anatoly Varivonchik。 我已经在Badoo工作了一年多,而我在Android方面的总体开发经验已经超过五年。
在我的实践中,我和我的同事经常面临着尽可能快速,简单地测试想法的需求。 我们不想在实现上花费很多精力,因为我们知道,如果实验不成功,那么我们将扔掉代码。
在本文中,我将通过实际示例展示在这种情况下我们如何行动以及哪些原则可以帮助我们做出选择,以选择特定的问题解决方案。 对示例的分析应该有助于理解我们的思维方式:您有时如何偷工减料,加快开发速度。

计划是这样的:
- Badoo中的开发方法原理。
- 案例研究。
- 设计系统。
- 何时应用所述原理。
本文是我在AppsConf上的报告的文本版本,可在
此处查看视频。
发展方针原则
数以亿计的人使用Badoo,因此,如果我们不确定用户是否会喜欢它并证明其有用性,那么我们将无法推出新功能。
我们的开发方法受到几个因素的影响。
使用A / B测试
今天,我们在移动平台上进行了数十项A / B测试,而数百项已经完成。 因此,如果您在两个不同的设备上使用Badoo应用程序,则它们之间的差异很大,乍一看可能看不到。
为什么我们需要A / B测试? 重要的是要了解,产品经理认为哪些是必要的,甚至对我们而言似乎显而易见的,在现实中并不总是有用的。 有时我们必须删除一个月或两个月前编写的代码。 有时,为了了解新功能是否合适,测试新功能的想法很有意义。 如果用户喜欢该功能,那么我们已经可以在其开发上投入时间。
降低开发成本
当然,我们希望一切都能快速进行并变得美丽。 但是,并非总是能够在短时间内实现这一目标。 有时需要很多天。 为了避免这些问题,我们尝试通过预先评估任务成本并指出我们难以做到的事情和容易做到的事情来帮助产品经理。
大多数用户规则
想象一下,您拥有一个可以在所有设备上的所有场景下完美运行的功能,但是同时有一群使用中文设备的用户无法正常运行。 在这种情况下,可能不值得尽快解决问题,因为您最有可能承担更重要的任务。
我们如何加快发展
让我们看一些说明这些原理如何工作的示例。 在这里,我们将介绍我们在工作中遇到的实际案例以及解决方案。
首先,我建议您自己考虑如何解决这种情况。 然后,我将考虑每个选项,并解释为什么会出现/不适合我们的情况。
例子1.进度累积按钮
我们需要向用户展示圆角从0到1的电池进度累积贷款的过程。

解决方案有哪些?

选项A。我们不需要此图标。 我们必须要求设计师重做功能。 让这里只显示一些文本。
选项B。使用位图蒙版。 通过正确的组合,我们可以准确地满足我们的需求。

选项C:只需要几个图标,在客户端上对其进行硬编码,然后显示其中一个即可。

在我们的案例中,我们来到了解决方案B和C。我们将详细讨论。

为什么不
选择选项A ? 我们可以解决这个特定的问题,它并不复杂。 我们在iOS和移动网络中使用相同的设计。 因此,没有理由拒绝说我们不这样做,而我们需要提出不同的设计。

位图掩码(
选项B )是解决此问题的理想解决方案。 我们可以轻松地绘制一个圆角矩形。 我们可以轻松地在所需填充百分比上绘制一个常规矩形。 仍然可以混合它们并设置正确的设置。 之后,左侧的两个角都将消失。

在代码中,它看起来像这样:
data class GoalInProgress(val progress: Float) private val unchargedPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.MULTIPLY) } private fun mixChargedAndUncharged(canvas: Canvas) { drawFullyCharged(canvas) drawUnchargedPart(canvas) }
我删除了大部分代码。 在文章中阅读有关位图蒙版的更多信息:
https :
//habr.com/en/company/badoo/blog/310618/ 。 您还将从中学习如何混合使用蒙版,要实现什么效果以及它在性能方面如何工作。
该解决方案100%满足我们的要求,也就是说,可以显示从0到1的进度。
唯一的缺点:如果您以前从未这样做过,那么您将不得不花费时间找出位图蒙版。 此外,您仍然必须与它们一起玩,查看边缘情况并进行测试。 我认为整个过程大约需要四个小时。
选项C。我们只采用几种固定类型的图标,并根据进度显示其中一种。 例如,如果用户的进度小于0.5,则将显示一个空图标。 显然,此解决方案不能满足100%的要求。 但是对于其实现,您只需要编写五行代码并从设计者那里获得三个图标。
fun getBackground(goal: GoalInProgress) = when (goal.progress) { in 0.0..0.5 -> R.drawable.ic_not_filled in 0.5..0.99 -> R.drawable.ic_half_filled else -> R.drawable.ic_full_filled }
此外,在严重缺乏时间的情况下(如发布实时功能时的情况),该解决方案是最佳的。 它并不需要很多时间-您只需将其推出并在下一版本中将其替换为正确的精美解决方案即可。 实际上,就像我们当时所做的那样。
例子2.输入电话号码的行
下一个示例是输入电话号码。 特色:
- 国家/地区前缀在左侧;
- 前缀不能删除;
- 有凹痕;
- 前缀不可点击。

让我们考虑一下如何实现。

选项A:编写实现所需逻辑的自定义TextWatcher。 它将保留此前缀,保留空格,控制光标位置。
选项B:将此组件分为两个独立的字段。 从UI的角度来看,它将是相同的组件。
选项C:要求采用其他设计以使我们更轻松。

我们决定实施选项B。更详细地考虑。

要求不同的设计(选项C)是我们试图做的第一件事。 但是,产品坚持最初的想法。 如果企业坚持某种功能,那么我们的任务就是实施它。
乍一看,自定义TextWatcher(选项A)似乎是一个简单的解决方案,但实际上,有许多边缘情况需要处理。 例如,您需要:
- 以某种方式拦截单击前缀,然后更改光标位置;
- 另外缩进;
- 禁止删除,以使用户无法删除国家的空格或前缀。
当然,做到所有这些都是可能的,但是相当困难。 似乎有一个更简单的选择。
他真的被发现了:
<merge xmlns:android="http://schemas.android.com/apk/res/android" tools:parentTag="android.widget.LinearLayout"> <TextView android:id="@+id/country_code" /> <EditText android:id="@+id/phone_number" /> </merge>
我们仅将该组件分为两部分:TextView和EditText。 以编程方式,在TextView上,我们以某种方式设置背景,以便准确地获得产品期望的设计。
唯一值得考虑的是,在Android中,默认情况下,当EditText成为焦点时,底线的宽度会增加。 但是,我们可以轻松地关注焦点变化并更改背景的前缀。 没什么复杂的:
phoneNumber.setOnFocusChangeListener { _, hasFocus -> countryCode.setBackgroundResource(background(hasFocus)) } private fun background(hasFocus: Boolean) = when (hasFocus) { true -> R.drawable.phone_input_active false -> R.drawable.phone_input_inactive }
该解决方案具有以下优点:
- 无需处理对前缀的点击;
- 无需处理光标位置-它始终位于单独的字段中。
- 这种实现方式很少出现边缘情况和问题。
例子3.自动完成的问题
如您在左侧动画中所见,自动完成功能无法按我们希望的那样工作。 我们希望一切看起来像右边的动画。


让我们考虑一下我们可以做些什么。

选项A:这似乎是一种罕见的情况,没有人解决。 我们为什么不这样做?
选项B:自定义TextWatcher将做得更好,并解决了我们所有的问题。
选项C:取消字符数限制(如动画所示,此组件中有一定数量的字符)。 我们会将带有前缀的整个电话号码发送到服务器,然后让服务器确定该号码是否有效。
选项D:从末尾开始输入N个字符。
我们选择了选项D。

选项A。我查看了几个大型应用程序。 似乎没有人修复它。
然而,将来越来越多的领域将充满自恋者。 您越早解决这个问题,您的用户就会越忠诚,您自己使用该应用程序就会越愉快。 例如,当我两次单击浏览整个屏幕时,我感到非常高兴。
选项B。实现自定义TextWatcher确实很容易,因为边缘脚本的数量不如上例所示。 您可以轻松拦截插入的文本。 只有一个小问题:在某些国家/地区,存在本地别名。 例如,+ 44和0表示同一件事。
自定义TextWatcher在这里无济于事。 在这种情况下,您需要编写其他逻辑,还要求服务器返回该国家/地区的所有可能的本地别名。 要解决此问题,您将必须更改与服务器的通信协议,然后在服务器上实现此功能。 这比在客户端上执行操作要花费更多时间。 似乎有一个更简单的解决方案(我们会来解决)。
选项C。我们取消字符数限制-然后服务器进行验证。 这是一个很好的选择。 可以将前缀显示两次。 如果用户继续进行下一步并且有效地确定了电话号码,则原则上不会有问题。
但是仍然有一个障碍。 假设用户不使用自动完成功能,而只是输入他的电话号码。 在这种情况下,如果字符数有限制,那么他意外地复制一个数字将变得更加困难-最终,他将看到最后一个数字没有被打印。 因此,我们决定不使用此方法。
选项D。从头开始使用N个字符对我们来说似乎是一个合适的解决方案。
class DigitsTrimStartFilter(private val max: Int) : InputFilter { override fun filter(...): CharSequence? { val s = source.subSequence(start, end).filter { it.isDigit() } val keep = max - (dest.length - (dend - dstart)) return when { keep <= 0 -> "" keep >= s.length -> null
我们拥有可以插入的电话号码的最大长度。 我们正在编写一个简单的类,它被封装并且可以在其他地方重用。 此外,当任何其他开发人员看到该代码时,他将迅速弄清楚是什么。 但是还有另外两个问题。
首先,有些国家的电话号码不同。 在这种情况下,我们的解决方案将在前缀旁边显示一个额外的数字。 其次,如果用户使用自动文件为另一个国家/地区插入前缀,则可能会发生同样的情况。 第二种情况对我们来说似乎很罕见,因为服务器最初会根据用户所在的国家/地区返回电话号码。 但是,如果我们知道这是一个问题,我们将不得不更改服务器上的协议,以便它一次返回所有数字的列表,并编写其他逻辑(现在我们不再认为这是必需的)。
例子4.日期输入组件
设计师和产品希望看到一个用于输入日期的掩码,如下所示:

让我们考虑一下如何实现。

选项A:随便做吧。 任务看起来很简单,很容易解决,不会出现任何问题。
选项B:使用遮罩库。 在这种情况下,她适合我们。
选项C:禁用对光标位置的控制。 因此,我们稍微简化了需求,并且使我们更容易实现此功能。
选项D:使用我们都看到的Android上的标准日期输入组件。
我们来到选项C。

选项A。任务似乎很简单。 当然,我们不是第一个实现此功能的人。 为什么不看看互联网上是否有合适的解决方案。

我们采用此解决方案,将其添加到代码中,然后运行它。 我们开始测试:
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { if (edited) { edited = false return } var working = getEditText() working = manageDateDivider(working, 2, start, before) working = manageDateDivider(working, 5, start, before) edited = true input.setText(working) input.setSelection(input.text.length) }
乍一看,它似乎或多或少适合我们。 没错,在每次更改后光标位置跳到末尾都是有问题的。 然后,我们开始进行更仔细的测试,并了解一切都不太好,并且存在无法解释的情况。

我们都需要完善这一点。 我想避免这种情况,因为这是测试人员的工作,然后我们的工作是修复错误等。
选项B:为什么不使用现成的库作为
装饰蒙版或
输入蒙版android ? 他们测试了所有场景,您可以重用一切并享受生活。 如果您的项目中有用于遮罩的库,或者准备添加它,那么这是一个很好的解决方案。
我们没有图书馆。 为了一个很小的组件而将其拖到项目中,而这个组件在其他任何地方都没有使用,这似乎是多余的。
选项D。使用标准日期输入组件。

这似乎是最聪明的解决方案。 除了一个小缺陷,他一切都很好。 当您打开此组件时,您已经有一些预定义的值和一些有效的日期。 如果您设置一个有效的日期进行下一步,例如1980年1月1日,那么您将收到当天出生的数百万用户。 否则,您将收到许多相同的错误:用户年龄太大或太小而无法注册。
因此,我们一次放弃了Badoo注册表中的标准日期输入对话框。 有关无效日期的错误数量已减少了三倍。

还有一个小的减号。 似乎只有高级用户才能从第一种状态转移到第二种状态:

如果不仅高级用户使用您的应用程序,他们还将逐月排序以查找所需日期。
因此,我们认为选项A还不错。 您只需要对其进行优化和简化即可:
class DateEditText : AppCompatEditText(context, attrs, defStyleAttr) { private var canChange: Boolean = false private var actualText: StringBuilder = StringBuilder() override fun onSelectionChanged(selStart: Int, selEnd: Int) { super.onSelectionChanged(selStart, selEnd) if (!canChange) return canChange = false setSelection(actualText.length) canChange = true } }
当用户更改光标位置时,选项A的缺点开始出现。 我们想到:“为什么要给这个机会移动光标?” 并被禁止这样做。

因此,我们解决了所有问题。 产品具有适合他们的实现。 并且如果将来他们决定您仍然需要机会从中间删除字符,我们会做到的。
例子5.视频流屏幕上的工具
在开始视频流传输时,这些产品希望显示工具提示,以教用户如何使用该功能。
在实现功能时,我们有六种工具提示。 同时,屏幕上不应有多个。 工具提示会在随机时间从服务器动态到达。 有些必须重复。 如果出现了工具提示,但用户没有单击它,则N分钟后应该会再次出现。

所有这些似乎很难实现。 我们问产品一些问题。
首先,添加分类器,对工具提示进行优先排序。 无论如何,当一个和另一个工具提示都希望同时出现并且必须选择其中之一时,就会出现这种情况。 因此,我们需要优先事项。 其次,我们要求进行一些简化:仅为最高优先级的工具提示保留一个计时器。
以前,工具提示重复计时器是独立的:

我们要求仅针对优先级最高的工具提示支持计时器:

因此,计时器仅对工具提示1起作用。对工具提示1来说,计时器一经删除,便开始了下一个过程。

因此,我们简化了需求:对于我们来说,实现该功能变得更加容易,对于测试人员来说,对其进行测试也更加容易。 最后,我们意识到这个决定适合每个人。
例子6.重新排序照片
这种设计来到了我们:

我们得出的结论是,实施起来非常困难,我们将不得不花三天的时间进行开发,然后想到:“如果我们不知道用户是否需要它,为什么要这样做?” 我们建议从简化版本开始,然后评估此功能的需求量。

原来,用户对此功能感兴趣。 之后,我们将重新渲染改进为原始设计的状态。
总计:
- 我们保护了我们自己和公司,避免了花太多时间在可能无用的功能上的风险;
- 结果,产品要求得到了全面实施。
例子7. PIN输入组件
我们不仅在开发Badoo应用程序-我们还开发其他设计完全不同的应用程序。 在这三个应用程序中,我们使用相同的组件来输入PIN码:

从UX的角度来看,组件的行为应相同。 但是,在不同的应用程序中,会使用不同的字体,缩进甚至不同的背景。 我不想将其复制到每个应用程序中,而是要重用它。 设计系统可以帮助我们。
设计系统是一组关于某些组件的行为的UX规则。 例如,我们已经明确指出,每个按钮必须具有某些状态,并且必须以某种方式运行。



您可以从
Rudy Artyom的报告中了解有关设计系统的更多信息。
同时,返回到PIN输入组件。 我们想要什么?
- 正确的键盘行为;
- 完全定制UI的能力,以使其在不同的应用程序中看起来有所不同;
- 像常规EditText一样,从此组件接收标准数据流。

我们的解决方案有哪些?

选项A:使用四个单独的EditText,其中每个PIN元素将是一个单独的EditText。
选项B:使用一个EditText,增加一些创造力-并获得所需的东西。
我们选择了选项B。

选项A。四个单独的EditText存在问题。 Android在所有方面都添加了额外的填充,我们需要正确处理。 此外,您将需要执行一次长按操作,以便用户删除整个PIN码。 我们将不得不手动处理焦点并处理字符删除。 似乎相当复杂。
因此,我们决定作弊,并创建了一个不可见的EditText,其大小为0乘以0,它将作为数据源:
private fun createActualInput(lengthCount: Int) = EditText(context) .apply { inputType = InputType.TYPE_CLASS_NUMBER isClickable = false maxHeight = 0 maxWidth = 0 alpha = 0F addOrUpdateFilter(InputFilter.LengthFilter(lengthCount)) } private fun createPinItems(count: Int) { actualText = createActualInput(count) actualText.textChanges() .subscribe { updatePins(it.toString()) pinChangesRelay.accept(it) } overlay.clicks().subscribe { focus() } }
PIN码的每一位都将以编程方式添加。 因此,我们可以绘制任何类型的UI,放置任何缩进等。在用户单击组件之后,我们将焦点放在EditText中。 因此,我们得到了一个可以正常工作的键盘。
另外,我们订阅以更改不可见EditText的文本并将其显示在UI上。 之后,我们很容易从该组件中获取数据流。 实际上,我们重用了标准的Android EditText,只是稍微添加了必要的逻辑。
总结
这些原则并非始终适用。 我会给您提供条件,使其可以正常工作。
- 开发人员具有影响功能的能力 。 否则,他只需要完成任务即可。
- 开发人员在一家产品公司工作,该公司 积极地共享和发布功能 ,并迅速检查有关这些功能的假设 。 在这种情况下,这些原则将得到充分体现,因为从一开始,我们再也无法百分百确定哪些更新将使用户满意,而哪些更新将使用户不满意。
- 开发人员具有分解任务的能力 。 在产品经理和开发人员进行双向交流的情况下,这些原则是合乎逻辑的解决方案,使双方都可以找到可以并且应该重做的内容。
- 外包 。 在极少数情况下,客户可能会对提案感兴趣,例如,通过简化部分功能来减少完成任务所需的时间。
如何使用这些原则? 不幸的是,在上下文之外,很难提出任何建议。 但是,我建议您注意以下事项。
在大多数示例中,您可能对UI / UX有问题,或者在工具提示示例中,可能对业务逻辑有问题。 您需要尝试将任务分解为几个小的子任务,然后对其进行评估。
之后,您可以准确地找到问题所在。 接下来,您与同事讨论如何解决他们。 也许可以简化一些事情。 或者,也许您只是不了解同事已经知道的一些简单解决方案。 在下一阶段,您将与产品协调替代解决方案。 如果他们满意,请执行您的报价。
我想补充一点,所有人有时都会犯错。 , , . , iOS. , . , . . , Win-Win .
:
PS , . , , . — , . ? .