3

В сети есть множество решений, как показывать текущее время в приложении. Есть решения и с таймерами, и асинхронные.

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

  • Пропуск секунд. Для тех, кто устанавливает интервал обновления на 1000 мс, вызов происходит реже, так как таймеры отчитывают секунду от окончания выполнения предыдущего обработчика до следующего. А не вызвают его ровно раз в секунду. И при попадании на границу смены значения секунд, часы подлагивают, пропускают секунду, например 10:33:12 => 10:33:14.
  • Поллинг. Есть решения похитрее, они обновляют время на экране не раз в секунду, а например 5 или даже больше раз в секунду. Чем чаще обновление - тем актуальнее время на экране. При обновлении от 10 раз в секунду и выше лаг получается максимум в плюс-минус интервал обновления, то есть при 10 обновлениях в секунду, интервал между сменой секунд будет от 0,9 до 1,1 секунды, что в принципе практически незаметно глазу. Но здесь другой минус - частые обновления интерфейса + частые запросы системного времени дают лишнюю нагрузку на систему.

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

aepot
  • 49,560
  • минус - частые обновление интерфейса + частые запросы системного времени - интерфейс не обязательно обновлять так же часто, как делаются запросы. дают лишнюю нагрузку на систему - насрать. Щас найду кое-что... – Alexander Petrov Apr 26 '21 at 23:43
  • 1
    тыц - будь как Майкрософт, жги мегаватты! PS: только что проверил у себя - Visual Studio разогнала таймер :( – Alexander Petrov Apr 26 '21 at 23:46
  • @AlexanderPetrov интерфейс не обязательно обновлять так же часто, как делаются запросы - этот пост не о полумерах. :) – aepot Apr 26 '21 at 23:51

1 Answers1

3

Требования при разработке решения были следующими:

  • Кросплатформенность, не зависеть от конкретного окружения или типа приложения.
  • Реализовать так просто, насколько это возможно.

Вот такое решение получилось:

public class ClockTimer
{
    private readonly Action<DateTime> _action;
    private CancellationTokenSource _cts;
public ClockTimer(Action&lt;DateTime&gt; action) 
    =&gt; _action = action;

public async void Start()
{
    if (_cts != null)
        return;
    try
    {
        using (_cts = new CancellationTokenSource())
        {
            while (true)
            {
                DateTime date = DateTime.Now;
                _action(date);
                await Task.Delay(1000 - date.Millisecond, _cts.Token);
            }
        }
    }
    catch (OperationCanceledException) { }
    catch (Exception ex)
    {
        Debug.Fail(ex.ToString());
    }
    _cts = null;
}

public void Stop() 
    =&gt; _cts?.Cancel();

}

Здесь убиваю 2 зайцев: и точно раз в секунду вызываю обновление, при чем обновление происходит в сразу через .001-.020мс после смены секунд на часах, и сразу передаю уже полученную из системы дату в делегат. Фактическая девиация генерируется только расходами на асинхронность.

Я проверил визуально в WPF вот таким образом

ClockTimer clock = new ClockTimer(d => Text = d.ToString("HH:mm:ss.fff"));
clock.Start();

Где Text - свойство, к которому привязан TextBlock, и сравнил с обновлением системных часов Windows в трее. Смена минут происходит либо одновременно, либо в приложении даже раньше.

Для наглядности работы, испытал и в консоли

static void Main(string[] args)
{
    ClockTimer clock = new ClockTimer(d => Console.WriteLine(d.ToString("HH:mm:ss.fff")));
    clock.Start();
    Console.ReadKey();
}

Вывод консоль

02:10:25.431
02:10:26.077
02:10:27.019
02:10:28.009
02:10:29.011
02:10:30.005
02:10:31.021
02:10:32.012
02:10:33.011
02:10:34.017
02:10:35.009
02:10:36.016
02:10:37.014
02:10:38.009

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

При лагах в системе, перенагрузке на процессор или подвисаниях, делегат вызовется сразу как только будет возможно, и далее снова стабилизируется как при первичном запуске, и это отвечает условиям. Даже если в сам делегат воткнуть например Thread.Sleep(500), подвешивая поток, вызов все равно будет ровно 1 раз в секунду, хоть и с опозданием на те самые 500мс от момента смены секунд на часах.

aepot
  • 49,560
  • При этом точность вызова делегата в ~10 раз выше, чем при поллинге 10 раз в секунду. это с чем вы сравниваете? С каким то конкретным кодом? – tym32167 Apr 26 '21 at 23:24
  • @tym32167 while (true) { ...; Thread.Sleep(100); } например. – aepot Apr 26 '21 at 23:25
  • кстати, важно понимать разницу между обновлением раз в секунду и обновлением каждую секунду. Например, при таймере с интервалом в 1 секунду у вас будет погрешность накапливаться. А если каждый раз считать время, когда вам удобно обновиться в след раз, то ясно что это будет точнее, вы же коррекцию делаете на каждой итерации. – tym32167 Apr 26 '21 at 23:25
  • @tym32167 именно эту проблему я и решал для себя. :) Убрал сомнительную фразу. – aepot Apr 26 '21 at 23:26
  • Тогда по сути можно либо как вы решить задачу, либо таймером. Только таймер тут будет хитрый - он будет одноразовый, то есть на каждое событие таймера его можно просто перенастраивать, когда он это событие вызовет ещё раз. – tym32167 Apr 26 '21 at 23:35
  • Ещё я бы сделал ClockTimer - IDisposable. В Dispose я бы затирал _action в null, чтобы не держать ссыли на то, что уже не надо. – tym32167 Apr 26 '21 at 23:35
  • @tym32167 я думал про IDisposable, но GC все равно пока машина состояний работает, не будет собирать этот экземпляр, то есть токен в любом случае надо канселить. Финализер убрал из решения по этой же причине, он бесполезен. – aepot Apr 26 '21 at 23:37
  • @tym32167 здесь больше была задача релизовать рабочее решение, кому надо - могут добавить сюда что угодно, включая IDisposable. Я пытался как можно проще оставить. – aepot Apr 26 '21 at 23:42
  • тут вопрос не про GC, а про то, как этот класс использовать. То есть, публикуя IDisposable вы как бы явно говорите програмисту, что этот класс надо очистить. Вам же надо будет как то отпустить ресурсы, захваченные экшоном. – tym32167 Apr 26 '21 at 23:43
  • 1
    Да я чисто вам идеи накидываю, можете оставить ответ как есть, я не против ) – tym32167 Apr 26 '21 at 23:43
  • @tym32167 я понял идею, IDisposable - неплохо здесь. Но больше зависит от конкретной задачи и начинки самого экшна. Оставлю его пожалуй как есть. Весь смысл поста в том, что я полдня рыл все возможные интернеты и даже намека на такое решение не нашел. Хотя оно с виду вообще суперпростое. Поэтому и решил запостить сюда, вдруг кому пригодится. – aepot Apr 26 '21 at 23:46
  • Почему делегат, а не привычное всем событие? Ну и дальше накрутить, чтобы вызов делегата происходил асинхронно и не задерживал таймер :) – Андрей NOP Apr 27 '21 at 05:07
  • @АндрейNOP Мне показался экшн проще, к тому же немутабельный. Развязка типа Producer/Consumer здесь мне кажется была бы перебором в плане сложности. – aepot Apr 27 '21 at 06:41
  • Ну иммутабельность Action здесь особо ничего не дает, но если я захочу несколько подписчиков, придется писать не совсем удобный код, непривычный, скажем так. Да и возможность подписки/отписки на лету не лишняя. Ну да ладно, в любом случае это ваша реализация под ваши задачи, тут я умываю руки – Андрей NOP Apr 27 '21 at 14:10