Признаюсь: я писал поддельный экран загрузки

в 16:14, , рубрики: silverlight, usability, загрузка, интерфейсы, КодоБред, полоса загрузки, фейк, юзабилити
Признаюсь: я писал поддельный экран загрузки - 1

На выходных посмотрел видео Алексея Макаренкова с заголовком “Полоса загрузки - не то, чем кажется…”, где он рассказывает как разработчики игр мухлюют с полоской загрузки.

Вкратце: полоска загрузки в играх - фейк, могла двигаться как угодно, но движется рывками, человеческое восприятие считает именно такой сценарий загрузки самым правдоподобным, а в плавную загрузку игроки не верят. Лучше один раз увидеть, чем сто раз услышать, вот это видео: Полоса загрузки - не то, чем кажется... (осторожно, присутствует реклама красного банка).

Но если смотреть лень, то дальше Алексей говорит о том, что это и так было предсказуемо - секрет Полишинеля, но об этом никто, как правило, не говорит. Когда люди узнают правду, это их “слегка” удивляет. Более того, в статьях и лекциях девелоперов, даже в тех которые посвящены дизайну экранов загрузки, о фейках не пишут.

И тут я могу попытаться заполнить пробел, и рассказать про то, как создавал фейковый экран загрузки. Нет, я не разработчик игр, однако играми экраны загрузки не ограничиваются. Лично я писал такой муляж для приложения на Silverlight. Как давно, это было, помнит только мутной реки вода: все сроки давности уже прошли, про это приложение, да и про Silverlight, уже все позабыли, так что можно снять гриф секретности, сдуть пыль со старого кода и вспомнить как это было.


Олды тут? Вместо дисклеймера

В публикации будет некрокод, с учётом того, что Silverlight уже не поддерживается, буду исходить из предположения что никто разбираться в этом не желает, постараюсь давать пояснения, достаточные для формирования представления и понимания. Всё-таки статья не про Silverlight, а про то, “как разработчики обманывают с экранами загрузки”.

Проблема. Вместо введения

Нам экран загрузки изначально не особо был нужен, и уж тем более не было цели кого-то обманывать. В проекте присутствует индикатор загрузки по умолчанию, он справлялся со своей обязанностью, даже ничего писать не надо, типичный код, по-моему, генерируется при создании проекта:

aspx страница
<%@ Page Language="c#" AutoEventWireup="true" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" >
    <head id="Head1" runat="server">
        <title>Silverlight Client</title>

        <style type="text/css">
        html, body {
	        height: 100%;
	        overflow: auto;
        }
        body {
	        padding: 0;
	        margin: 0;
        }
        #silverlightControlHost {
	        height: 100%;
	        text-align:center;
        }
        </style>

        <script type="text/javascript" src="Silverlight.js"></script>

        <script type="text/javascript">
            function redirect(url) {
                window.location.href = url;
            }

            function onSilverlightError(sender, args) {
                var appSource = "";
                if (sender != null && sender != 0) {
                    appSource = sender.getHost().Source;
                }

                var errorType = args.ErrorType;
                var iErrorCode = args.ErrorCode;

                if (errorType == "ImageError" || errorType == "MediaError") {
                    return;
                }

                var errMsg = "Unhandled Error in Silverlight Application " + appSource + "n";

                errMsg += "Code: " + iErrorCode + "    n";
                errMsg += "Category: " + errorType + "       n";
                errMsg += "Message: " + args.ErrorMessage + "     n";

                if (errorType == "ParserError") {
                    errMsg += "File: " + args.xamlFile + "     n";
                    errMsg += "Line: " + args.lineNumber + "     n";
                    errMsg += "Position: " + args.charPosition + "     n";
                }
                else if (errorType == "RuntimeError") {
                    if (args.lineNumber != 0) {
                        errMsg += "Line: " + args.lineNumber + "     n";
                        errMsg += "Position: " + args.charPosition + "     n";
                    }
                    errMsg += "MethodName: " + args.methodName + "     n";
                }

                throw new Error(errMsg);
            }
        </script>
    </head>

    <body>
        <form id="form1" runat="server" style="height:100%">
            <div id="silverlightControlHost">
                <object data="data:application/x-silverlight-2," type="application/x-silverlight-2" width="100%" height="100%">
		          <param name="source" value="ClientBin/MainApplication.xap"/>
                  <param name="windowless" value="true"/>
		          <param name="onError" value="onSilverlightError" />
		          <param name="background" value="#FFDFF0F8" />
		          <param name="minRuntimeVersion" value="4.0.50826.0" />
		          <param name="autoUpgrade" value="true" />
		          <a href="http://go.microsoft.com/fwlink/?LinkID=149156&v=4.0.50826.0" style="text-decoration:none">
 			          <img src="https://go.microsoft.com/fwlink/?LinkId=161376" alt="Get Microsoft Silverlight" style="border-style:none"/>
		          </a>
	            </object>
                <iframe id="_sl_historyFrame" style="visibility:hidden;height:0px;width:0px;border:0px">
                </iframe>
            </div>
        </form>
    </body>

</html>

Указывается xap-файл, и пока он грузится - идёт индикатор загрузки: вращающееся колесо и число показывающее процент загрузки в зависимости от размера скачиваемого xap-файла: скачалось 2 Мб из 8, покажет 25%. Это поведение по умолчанию - ничего дополнительно писать не надо.

Всё было хорошо пока в один прекрасный день, в который никто ничего не трогал, оно само, размер скачиваемого xap-файл ни стал оцениваться в 0 байт. Само собой файл не стал невесомым, просто, почему-то, при скачивании, кто-то, или что-то, зарезал заголовок с размером файла.

На экране загрузке гордо крутилось колесо с надписью 0%, висели эти 0% относительно долго, обычно загрузка занимала пару минут, и потом резко 100%...

На поиск решения потратили день - решить с наскоку не получилось. С другой стороны жалко тратить на это время: ошибки то вроде нет, ну и что что полоска загрузки висит на нуле долгое время, - на работе приложения это не сказывается никак, да неприятно, поэтому проблему не стали сбрасывать, но решили что приоритет у неё невысокий и будет она решаться в свободное от других задач время.

Прошла неделя, периодически к этой задаче возвращались, но решение найдено не было.

Прошло ещё некоторое время. И тут начали возмущаться уже пользователи, мол висит индикатор загрузки, кэш чистили, куки чистили, компьютер перезагружали, браузер меняли, а он на нуле и ничего не грузит, и у всех такое дело. Что с приложением стало? Объясняли что так мол и так, - не надо суеты, ждите и всё будет. Пользователи набирались терпения, убедились что всё работает, но осадочек остался, и чтобы не разводить панику надо было индикатор загрузки чинить.

Вернулись к задаче, прошла ещё пара дней, а причину почему оценка размера xap-файла равна нулю, мы не нашли и даже никаких соображений на этот счёт не осталось.

В этот то момент мы и встали на скользкую дорожку. Ну самое очевидное - пользователи ведь жалуются не на то, что размер файла не определяется, а на то, что полоска загрузки замерла, на файл то им плевать с высокой колокольни.

Искушение злом. Вместо оправдания

Да, ключ к решению проблемы лежал в плоскости "вернуть правильный заголовок" и всё станет как было, но здесь мы ничего не добились. Потраченного времени жаль, - тратить его на эту “не ошибку” мы не были готовы изначально, а когда поиски не приводят к результату а приводят к ещё большей трате - так время жаль вдвойне. В итоге решили поискать решение в другой плоскости.

Мы примерно знали сколько времени занимает загрузка (замеряли), понятно что эта величина непостоянная, зависит от сети, но при типичном сценарии загрузка колебалась в районе двух минут. Соответственно нам нужно было написать экран загрузки который бы развлекал пользователей это время. На самом деле чуть больше - на всякий случай с запасом.

Реализация обмана. Вместо охоты на баг

К счастью в Silverlight задача кастомизации экрана загрузки - типичная, нацелена не на фейковые экраны, а на всякое украшательство, но так или иначе гуглится легко, а там уже кто какие цели преследует - кто украшательство, кто подделку полосы прогресса. Нужно добавить два параметра splashscreensource и onsourcedownloadprogresschanged:

<div id="silverlightControlHost">
    <object data="data:application/x-silverlight-2," type="application/x-silverlight-2" width="100%" height="100%">
		<param name="source" value="ClientBin/MainApplication.xap"/>
        <param name="splashscreensource" value="LoadScene.xaml" />
        <param name="onsourcedownloadprogresschanged" value="onSourceDownloadProgressChanged" />
		<param name="windowless" value="true" />
		<param name="onError" value="onSilverlightError" />
		<param name="background" value="white" />
		<param name="minRuntimeVersion" value="4.0.50826.0" />
		<param name="autoUpgrade" value="true" />
		<a href="http://go.microsoft.com/fwlink/?LinkID=149156&v=4.0.50826.0" style="text-decoration:none">
 			<img src="https://go.microsoft.com/fwlink/?LinkId=161376" alt="Get Microsoft Silverlight" style="border-style:none"/>
		</a>
	</object>
    <iframe id="_sl_historyFrame" style="visibility:hidden;height:0px;width:0px;border:0px"></iframe>
</div>

Первый - это визуальное представление, xaml-файл (LoadScene.xaml):

Макет полосы загрузки

Макет полосы загрузки

Второй - это скрипт для обработки загрузки.

Изначально это была просто полоска. Не знаю как так получилось, но со временем полоска, предназначенная для того чтобы заполняться равномерно в течении двух минут, превратилась в две: одна чтобы показывать общий прогресс, вторая чтобы показывать загрузку "текущего" модуля:

Макет экрана загрузки

Макет экрана загрузки

Откуда мы знаем какой модуль загружается и сколько времени это займёт? Да ниоткуда - это тоже подделка. Обычный массив со списком строк, которые якобы названия модулей. Названия выводятся вместо слова "загрузка".

В итоге пользователь видит что нижняя полоса - прогресс модуля, загружается достаточно быстро, вероятно это и был ожидаемый эффект: система очень быстро грузит отдельные модули, а долго загружается потому что модулей много.

Ниже представлена XAML-разметка второго варианта:

LoadScene.xaml
<Grid xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <Grid HorizontalAlignment="Center" VerticalAlignment="Center">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
            
        <Image Grid.Row="0" 
               Source="../images/header/header-left.png" 
               VerticalAlignment="Top"
               Stretch="None" />
        
        <Image Grid.Row="1" Source="../images/back.png" Stretch="UniformToFill" />

        <Grid Grid.Row="1" Grid.ColumnSpan="3">
            <Grid.RowDefinitions>
                <RowDefinition Height="*"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="*"/>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition MaxWidth="310"/>
                <ColumnDefinition MaxWidth="50"/>
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>

            <Grid Grid.Column="1" HorizontalAlignment="Center" 
                  Width="300" Margin="5">
                <Rectangle Name="progressBarBackground"
                           Fill="White" Stroke="Black"
                           StrokeThickness="1" Height="20" Width="300" />
                <Rectangle Name="progressBar" HorizontalAlignment="Left"
                           Fill="#FF084c7c" Height="18" Width="0" MaxWidth="298" 
                           Margin="1,0,0,0" />
            </Grid>

            <Grid HorizontalAlignment="Center" Width="300" 
                  Grid.Row="1" Grid.Column="1" Margin="5">
                <Rectangle Name="progressBarBackground2"
                           Fill="White" Stroke="Black"
                           StrokeThickness="1" Height="20" Width="300" />
                <Rectangle Name="progressBar2" HorizontalAlignment="Left"
                           Fill="#FF084c7c" Height="18" Width="0" MaxWidth="298"
                           Margin="1,0,0,0" />
            </Grid>

            <TextBlock Grid.Column="2" x:Name="LoadingText" Margin="5"
                       HorizontalAlignment="Center"
                       VerticalAlignment="Center"
                       MinWidth="40"
                       Foreground="Black" FontWeight="Normal" 
                       FontFamily="Arial" FontSize="16" Text="0%"/>
            <TextBlock Grid.Row="1" Grid.Column="2" x:Name="LoadingText2" Margin="5"
                       HorizontalAlignment="Center"
                       VerticalAlignment="Center"
                       MinWidth="40"
                       Foreground="Black" FontWeight="Normal" 
                       FontFamily="Arial" FontSize="16" Text="0%"/>
            <TextBlock Grid.Row="2" x:Name="MessageText" Margin="5"
                        Grid.ColumnSpan="4"
                        HorizontalAlignment="Center"
                        VerticalAlignment="Center"
                        Foreground="Black" FontWeight="Normal" 
                        FontFamily="Arial" FontSize="12" Text="Загрузка"/>
        </Grid>
    </Grid>
</Grid>

Здесь у нас четыре квадрата: два для верхней полосы прогресса (progressBarBackground, progressBar), два для нижней.

По одному квадрату progressBarBackground и progressBarBackground2 - представляют пустую незаполненную полосу прогресса, и ещё по одному progressBar и progressBar2 меняют свою ширину по мере “загрузки” и тем самым иллюстрирует движение полосы прогресса.

Также здесь несколько текстовых блоков для отображения числа в процентах и названия исполняемого модуля.

Собственно для реализации анимации прогресса нужно сделать изменение ширины у progressBar и progressBar2, ну и надписи периодически менять.

Для всего этого необходимо реализовать onSourceDownloadProgressChanged, возвращаемся к aspx файлу:

<script type="text/javascript">

	var id = 0;

	var diff = ["Загрузка модуля справочников", "Загрузка модуля отображения информации", "Загрузка атрибутивных данных", "Загрузка модуля редактирования", "Формирование списка документов", "", ""];
	var i = 0;

	function onSourceDownloadProgressChanged(sender, eventArgs)
	{
		var val = sender.findName("progressBar").Width / sender.findName("progressBarBackground").Width;
		if (eventArgs.progress > val)
		{
			sender.findName("LoadingText").Text = Math.round((eventArgs.progress * 100)) + "%";
			sender.findName("progressBar").Width = eventArgs.progress * sender.findName("progressBarBackground").Width;

			if (eventArgs.progress >= 1 / 4 * (i + 1) || eventArgs.progress >= 0.98) {
				sender.findName("LoadingText2").Text = "100%";
				sender.findName("progressBar2").Width = sender.findName("progressBarBackground2").Width;
			}
		}

		if (id === 0)
		{
			sender.findName("MessageText").Text = diff[i];

			id = setInterval(function() {
				var rel = sender.findName("progressBar").Width / sender.findName("progressBarBackground").Width;
				rel += (Math.random() * 2 + 2) / 100;
				if (rel <= 0.96) {
					sender.findName("LoadingText").Text = Math.round((rel * 100)) + "%";
					sender.findName("progressBar").Width = rel * sender.findName("progressBarBackground").Width;
				}
			}, 3500);

			setInterval(function ()
			{
				var rel1 = sender.findName("progressBar").Width / sender.findName("progressBarBackground").Width;
				var rel2 = sender.findName("progressBar2").Width / sender.findName("progressBarBackground2").Width;
				rel2 += (Math.random() * 2 + 2) / 100;

				if (rel1 >= 0.96) {
					sender.findName("progressBar2").Width = sender.findName("progressBarBackground2").Width;
					sender.findName("LoadingText2").Text = "100%";
				}
				else if (rel2 >= 1) {
					sender.findName("progressBar2").Width = 0;
					sender.findName("LoadingText2").Text = "0%";
					i++;
				} else {
					sender.findName("LoadingText2").Text = Math.round((rel2 * 100)) + "%";
					sender.findName("progressBar2").Width = rel2 * sender.findName("progressBarBackground2").Width;
				}
				sender.findName("MessageText").Text = diff[i];

			}, 500);
		}
	}

</script>

На что тут можно обратить внимание, во-первых: на diff - это фейковый список загружаемых модулей, а i - это индекс текущего загружаемого модуля.

И во-вторых: на функцию onSourceDownloadProgressChanged, при нормальном сценарии, - если размер файла приходит корректный, она вызывается с некоторой периодичностью и в её параметрах содержится какая доля файла уже загружена, соответственно мы можем использовать это для честной визуализации. Однако в нашем случае функция вызывается всего два раза: в самом начале, когда загружено 0, и в самом конце, когда загружено 100%.

Этот код:

var val = sender.findName("progressBar").Width / sender.findName("progressBarBackground").Width;
if (eventArgs.progress > val)
{
	sender.findName("LoadingText").Text = Math.round((eventArgs.progress * 100)) + "%";
	sender.findName("progressBar").Width = eventArgs.progress * sender.findName("progressBarBackground").Width;

	if (eventArgs.progress >= 1 / 4 * (i + 1) || eventArgs.progress >= 0.98) {
		sender.findName("LoadingText2").Text = "100%";
		sender.findName("progressBar2").Width = sender.findName("progressBarBackground2").Width;
	}
}

Написан на всякий случай, чтобы не было накладок если ошибка с определением размера файла пропадёт так же внезапно, как и возникла.

В этом случае код должен попытаться правдоподобно скоординировать фейковый и реальный прогресс. По крайней мере чтобы это не сильно бросалось в глаза и наш обман не вскрылся.

Такого не случилось, но ожидаю что при таком стечении обстоятельств полоска будет заполняться плавно, как в фейковом алгоритме, и потом рывками, когда реальный прогресс загрузки начнёт обгонять поддельный.

Полоса загрузки модулей тоже начнёт двигаться рывками из-за условия в строках 7 - 10. Суть его в том, что если мы загрузили 25% от общего размера, то мы не должны показывать что грузится первый модуль, а писать уже про второй - с первым заканчивать. Если общий прогресс превысил 50%, то и второй модуль надо перестать грузить, показать что он загружен на 100% и переходить дальше и т.д. из расчёта 25% на модуль, - четыре модуля покажем и хватит.

Ну и если общий прогресс приближается к 100%, то и загружаемый сейчас модуль тоже должен сделать вид что полностью загружен.

На один листинг выше, в 22 строке есть условие

if (id === 0)

Сделано для тех же целей, - на случай если функция начнёт вызываться корректно. Если проверку условия не сделать - то запустится множество циклов в setInterval и полоска загрузки будет двигаться очень быстро, дойдёт до 100% и замрёт так на пару минут.

Думаю это отличает нашу поддельную полосу загрузки от большинства других подделок: мы предусмотрели корректировку относительно реального прогресса.

Теперь о самих интервалах. Их два.

Первый:

id = setInterval(function() {
	var rel = sender.findName("progressBar").Width / sender.findName("progressBarBackground").Width;
	rel += (Math.random() * 2 + 2) / 100;
	if (rel <= 0.96) {
		sender.findName("LoadingText").Text = Math.round((rel * 100)) + "%";
		sender.findName("progressBar").Width = rel * sender.findName("progressBarBackground").Width;
	}
}, 3500);

Раз в 3.5 секунды изменяет общую полосу прогресса на случайную величину от 2 до 4  процентов. Замирает на 96% и делает вид что осталось совсем чуть-чуть, но он замер на какой-то тяжёлой операции, после которой сразу 100% и приложение запущено. Обычно загрузка завершилась раньше чем он доходил до 96%.

Второй:

setInterval(function ()
{
	var rel1 = sender.findName("progressBar").Width / sender.findName("progressBarBackground").Width;
	var rel2 = sender.findName("progressBar2").Width / sender.findName("progressBarBackground2").Width;
	rel2 += (Math.random() * 2 + 2) / 100;

	if (rel1 >= 0.96) {
		sender.findName("progressBar2").Width = sender.findName("progressBarBackground2").Width;
		sender.findName("LoadingText2").Text = "100%";
	}
	else if (rel2 >= 1) {
		sender.findName("progressBar2").Width = 0;
		sender.findName("LoadingText2").Text = "0%";
		i++;
	} else {
		sender.findName("LoadingText2").Text = Math.round((rel2 * 100)) + "%";
		sender.findName("progressBar2").Width = rel2 * sender.findName("progressBarBackground2").Width;
	}
	sender.findName("MessageText").Text = diff[i];

}, 500);

Второй интервал управляет полосой загрузки модуля. Если основная полоса загрузки подвисла на 96%, то делаем вид что текущий модуль загружен на 100%, но к следующую модулю не переходим, даже если в списке ещё что-то есть. Так и остаётся.

В остальных ситуациях плавно доходим до 100%, увеличиваем i на единицу - доставая из массива “следующий модуль”, сбрасываем полосу прогресса загрузки модуля на 0, и всё сначала.

Загрузка “модуля” идёт в 7 раз быстрее “общей” загрузки, поэтому на всякий случай в массиве необходимо иметь 7 элементов, за границу массива не выйдет т.к. при достижении общего прогресса в 96% - мы перестаём инкрементировать переменную i. Хотя сейчас мне это не кажется надёжным, лучше было бы ещё сделать дополнительную проверку на значение i, ну да ладно.

Вот и вся реализация.

Заключение. Вместо покаяния

Таким образом мы дурим пользователя за его же деньги. И обмануть его не трудно! Он сам обманываться рад! И это не фигура речи, дословно не помню, но желание коллективного пользователя было сформулировано как-то так: “Сделайте хоть что-нибудь чтобы мы видели что приложение не зависло, и примерно представляли сколько ещё осталось ждать”.

С этой точки зрения мы достигли того чего хотел пользователь, приложение даже грузилось быстрее чем обещала полоса прогресса, как правило уже на 70-80% загрузка завершилась - приятный бонус за Ваше ожидание. Ну и никто больше не перезагружал страницу полагая что она зависла. Даже если бы она зависла на 96%, вряд ли бы кто-то нажал F5, ведь остался последний рывок и загрузка может завершиться в любой момент.

Если Вы читаете это как пользователь, не удивляйтесь что иногда полоса загрузки действительно не то чем кажется. Но я полагаю что в глубине души Вы и сами это давным-давно поняли, и даже готовы с этим мириться, и более того готовы простить нас - тех, кто подделывает экран загрузки, потому что почти всегда это ложь во благо.

Если Вы читаете это как разработчик - знайте, подделать экран загрузки это нормально, а порой необходимо.

Автор: Иван Ткаченко

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js