Разработка консольного менеджера загрузок на Kotlin с использованием Coroutines

Практический курс по созданию многопоточного загрузчика файлов для курсовой работы. Вы изучите работу с сетью, HTTP Range и применение Kotlin Coroutines для параллельного выполнения задач.

1. Проектирование архитектуры приложения и работа с HTTP-заголовками Range

Проектирование архитектуры приложения и работа с HTTP-заголовками Range

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

В качестве референса мы будем держать в голове логику работы таких гигантов, как Internet Download Manager или открытого проекта ab-download-manager, но наша цель — создать свой собственный, легковесный и понятный инструмент, работающий в консоли (CLI).

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

Зачем нам архитектура в консольном приложении?

Даже если вы пишете простое консольное приложение, сваливание всего кода в один файл Main.kt — это путь к хаосу. Хорошая архитектура позволит вам:

  • Легко менять UI. Сегодня это консоль, завтра — графический интерфейс на Compose for Desktop.
  • Тестировать логику. Вы сможете проверить алгоритм разбиения файла без реального скачивания гигабайтов данных.
  • Управлять сложностью. Разделение ответственности помогает держать в голове только одну часть системы за раз.
  • Основные компоненты системы

    Мы будем использовать упрощенную версию «Чистой архитектуры». Выделим три основных слоя:

    * Presentation Layer (UI): Отвечает за взаимодействие с пользователем. В нашем случае это чтение команд из консоли и отображение прогресс-бара. * Domain Layer (Бизнес-логика): «Мозг» приложения. Здесь будет жить наш DownloadManager, который решает, на сколько частей разбить файл и как управлять процессом. * Data Layer (Данные): Отвечает за низкоуровневые детали — выполнение HTTP-запросов и запись байтов на диск.

    !Диаграмма слоев архитектуры нашего менеджера загрузок

    Принцип работы параллельной загрузки

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

    Чтобы реализовать это, нам нужно:

  • Узнать полный размер файла.
  • Разделить этот размер на частей (где — количество потоков).
  • Попросить сервер отдать нам конкретный кусок файла для каждого потока.
  • Собрать куски воедино.
  • Здесь на сцену выходит протокол HTTP.

    Магия HTTP: Заголовок Range

    Обычно, когда вы делаете GET запрос к файлу, сервер начинает отдавать его с самого начала и до конца. Но протокол HTTP/1.1 поддерживает механизм Partial Content (Частичный контент).

    Ключевой элемент этого механизма — заголовок запроса Range.

    Формат заголовка

    Чтобы запросить только часть файла, клиент добавляет в запрос заголовок следующего вида:

    Где: * start — индекс первого байта (начинается с 0). * end — индекс последнего байта (включительно).

    Пример: Если мы хотим скачать первые 500 байт файла, мы отправим: Range: bytes=0-499

    Ответ сервера

    Если сервер поддерживает докачку и частичные запросы, он ответит не привычным кодом 200 OK, а специальным кодом:

    > 206 Partial Content

    Также сервер вернет заголовок Content-Range, который подтверждает, какую именно часть он передает.

    Пример ответа сервера:

    Здесь 1234 — это полный размер файла. Обратите внимание, что Content-Length теперь равен размеру куска, а не всего файла.

    Что если сервер не поддерживает Range?

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

    > 200 OK

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

    Математика разбиения файла

    Прежде чем писать код, давайте формализуем логику разбиения файла на части. Это критически важный момент, где новички часто совершают ошибки «на единицу» (off-by-one errors).

    Пусть у нас есть файл размером байт, и мы хотим скачать его в потоков.

    Сначала найдем базовый размер одного куска (чанка) :

    Где — полный размер файла в байтах, — количество потоков, а — операция округления вниз до целого числа (целочисленное деление).

    Теперь определим диапазон байтов для каждого потока с номером (где меняется от до ).

    Начало диапазона вычисляется просто:

    Где — байт начала загрузки для -го потока, — номер потока (начиная с 0), — размер чанка.

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

    Где — байт конца загрузки для -го потока. Мы вычитаем 1, так как нумерация байтов начинается с нуля (диапазон длиной 100 байт — это 0..99).

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

    Где — конец диапазона последнего потока, — полный размер файла.

    Пример расчета

    Допустим, размер файла байт, и мы качаем в потока ().

  • Размер чанка: (целочисленно).
  • Поток 0:
  • * Start: * End: * Диапазон: bytes=0-332 (333 байта)
  • Поток 1:
  • * Start: * End: * Диапазон: bytes=333-665 (333 байта)
  • Поток 2 (последний):
  • * Start: * End: * Диапазон: bytes=666-999 (334 байта)

    Итого: . Все байты на месте.

    Подготовка проекта на Kotlin

    Для реализации нашей курсовой работы нам понадобятся правильные инструменты. Мы будем использовать систему сборки Gradle (Kotlin DSL).

    Структура проекта

    Создайте новый проект в IntelliJ IDEA (New Project -> Kotlin -> Console Application). Структура папок должна выглядеть примерно так:

    Зависимости

    В файл build.gradle.kts нам нужно добавить две ключевые библиотеки:

  • OkHttp — мощный HTTP-клиент. Он возьмет на себя работу с заголовками, соединениями и потоками данных.
  • Kotlinx Coroutines — для асинхронной работы. Именно с их помощью мы будем запускать параллельные задачи легко и эффективно.
  • Добавьте в блок dependencies:

    Резюме

    Сегодня мы заложили фундамент нашего менеджера загрузок:

  • Определили архитектуру: разделение на UI, логику и сеть.
  • Разобрались с HTTP-заголовком Range, который позволяет скачивать файлы частями.
  • Вывели формулы для корректного расчета диапазонов байтов.
  • Подготовили проект, подключив OkHttp и Coroutines.
  • В следующей статье мы перейдем к практике: создадим сетевой клиент, научимся получать размер файла и реализуем первый простой алгоритм скачивания одного сегмента.

    2. Основы Kotlin Coroutines: Dispatchers, Scope и управление асинхронностью

    Основы Kotlin Coroutines: Dispatchers, Scope и управление асинхронностью

    В предыдущей статье мы спроектировали архитектуру нашего менеджера загрузок и разобрались с математикой HTTP-заголовка Range. Мы выяснили, что для ускорения загрузки файл нужно разбить на части и скачивать их одновременно. Но как реализовать это «одновременно» в коде?

    Если бы мы писали на Java 10 лет назад, мы бы создавали по одному системному потоку (Thread) на каждую часть файла. Однако потоки — это дорогой ресурс. Создание тысяч потоков может «убить» приложение. В Kotlin для решения этой задачи используется более элегантный и мощный инструмент — Coroutines (Корутины).

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

    Что такое Корутины?

    Часто корутины называют «легковесными потоками». Это хорошая аналогия, но важно понимать разницу. Поток операционной системы (OS Thread) — это тяжелая структура, на создание которой выделяется значительный объем памяти (обычно около 1 МБ на стек). Переключение между потоками (Context Switch) требует участия ядра процессора и занимает время.

    Корутина же — это не поток. Это задача, которая может быть приостановлена (suspended) и возобновлена позже, не блокируя поток, на котором она выполняется. На одном системном потоке могут «жить» тысячи корутин.

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

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

    Ключевое понятие: suspend function

    В основе корутин лежит модификатор suspend. Если вы добавите это слово перед функцией, она станет «приостанавливаемой».

    Функция delay — это аналог Thread.sleep, но с важным отличием: она не блокирует поток. Пока корутина «спит» (или ждет ответа от сервера), поток, на котором она выполнялась, освобождается и может выполнять другую полезную работу (например, рисовать UI или обрабатывать другой кусок файла).

    Когда ожидание заканчивается, корутина «просыпается» и продолжает выполнение, возможно, уже даже на другом потоке.

    Структурированная конкурентность (Structured Concurrency)

    В старых подходах к многопоточности была проблема: вы запускали поток, и он начинал жить своей жизнью. Если пользователь закрывал приложение или отменял загрузку, поток мог продолжать работать, тратя ресурсы и вызывая утечки памяти. Это называлось «fire and forget» (выстрелил и забыл).

    Kotlin предлагает принцип Structured Concurrency. Его суть проста: каждая корутина должна запускаться в рамках определенной области видимости — CoroutineScope.

    CoroutineScope

    Scope (область видимости) определяет жизненный цикл корутин. Если отменяется Scope, отменяются и все запущенные в нем корутины. Это идеально подходит для нашего менеджера загрузок:

  • У нас есть задача «Скачать файл» (Parent Scope).
  • Внутри нее мы запускаем 8 подзадач для скачивания частей (Child Coroutines).
  • Если пользователь нажимает «Отмена», мы просто отменяем родительский Scope, и все 8 соединений автоматически закрываются.
  • В консольном приложении мы часто будем сталкиваться с билдером runBlocking. Он создает мост между обычным синхронным миром (функция main) и асинхронным миром корутин.

    Примечание: В реальных UI-приложениях (Android, Desktop) runBlocking использовать не рекомендуется, но для main функции консольной утилиты это стандартный подход.

    Диспетчеры (Dispatchers): Где выполняется код?

    Корутина — это задача. Но кто-то должен эту задачу выполнять. Этим «кем-то» являются потоки. Диспетчер определяет, на каком именно потоке или пуле потоков будет запущена корутина.

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

    Основные виды диспетчеров:

  • Dispatchers.Main
  • * Используется для работы с UI (обновление прогресс-бара, кнопок). * Работает на главном потоке. В консольном приложении без UI-фреймворка этот диспетчер обычно недоступен или не нужен.*

  • Dispatchers.Default
  • * Предназначен для CPU-intensive задач (сложные вычисления, обработка JSON, криптография). * Количество потоков равно количеству ядер процессора. Если мы будем качать файлы через этот диспетчер, мы быстро упремся в лимит ядер, хотя процессор будет простаивать в ожидании сети.*

  • Dispatchers.IO
  • * Предназначен для IO-intensive задач (Input/Output): работа с сетью, чтение/запись файлов, запросы к базе данных. * Это именно то, что нам нужно! * Этот диспетчер создает большой пул потоков (по умолчанию до 64 или больше), позволяя множеству корутин одновременно ждать ответа от сервера.

    Переключение диспетчера

    В Kotlin очень легко переключаться между диспетчерами с помощью функции withContext. Это позволяет писать код последовательно, меняя контекст выполнения только там, где это нужно.

    Пример для нашего менеджера:

    kotlin suspend fun downloadFileParallel(urls: List<String>) = coroutineScope { // Создаем список задач val tasks = urls.map { url -> async(Dispatchers.IO) { downloadChunk(url) } }

    // Ждем выполнения всех и получаем список результатов val results: List<ByteArray> = tasks.awaitAll() mergeFiles(results) } ``

    Обратите внимание на функцию coroutineScope (с маленькой буквы). Это специальная функция, которая создает дочернюю область видимости. Она не завершится, пока не завершатся все запущенные внутри неё async или launch. Если внутри одной из загрузок произойдет ошибка (например, обрыв сети), coroutineScope автоматически отменит все остальные параллельные загрузки, чтобы не тратить трафик зря. Это и есть мощь структурированной конкурентности.

    Обработка ошибок и отмена

    В сетевом программировании ошибки неизбежны. Сервер может быть недоступен, интернет может пропасть. Корутины предоставляют механизм CancellationException.

    Важно помнить: отмена корутины носит кооперативный характер. Это значит, что если ваша корутина выполняет тяжелый цикл вычислений и не проверяет, отменили её или нет, она не остановится.

    Однако, все стандартные функции из библиотеки kotlinx.coroutines (такие как delay, withContext) и сетевые вызовы (если они правильно обернуты) автоматически поддерживают отмену. В нашем случае, использование OkHttp внутри Dispatchers.IO позволит корректно прерывать загрузку при отмене Job.

    Резюме

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

  • Корутины — это не потоки, а задачи, которые могут приостанавливаться.
  • Dispatchers.IO — наш лучший друг для сетевых запросов и работы с диском.
  • Structured Concurrency гарантирует, что мы не потеряем контроль над запущенными загрузками и сможем отменить их одной командой.
  • async/await — механизм для запуска параллельных задач и получения их результатов.
  • В следующей статье мы перейдем от теории к практике: напишем класс HttpClient` на базе OkHttp и реализуем первый реальный запрос к серверу с использованием корутин.

    3. Реализация логики параллельной загрузки сегментов и сборка итогового файла

    Реализация логики параллельной загрузки сегментов и сборка итогового файла

    Мы прошли долгий путь подготовки. Мы спроектировали архитектуру, разобрались с математикой HTTP-заголовка Range и изучили основы Kotlin Coroutines. Теперь у нас есть все необходимые кирпичики, чтобы построить стену. В этой статье мы напишем «сердце» нашего менеджера загрузок — код, который разбивает файл на части, скачивает их параллельно и собирает воедино.

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

    Подготовка сетевого клиента

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

    Нам понадобятся две основные функции:

  • getContentLength: чтобы узнать общий размер файла перед загрузкой.
  • downloadPart: чтобы скачать конкретный кусок байтов.
  • Получение размера файла

    Чтобы узнать размер файла, не скачивая его, используется HTTP-метод HEAD. Он возвращает только заголовки.

    Обратите внимание на использование withContext(Dispatchers.IO). Сетевые запросы — это блокирующие операции ввода-вывода, поэтому мы обязаны выполнять их на соответствующем диспетчере, чтобы не замораживать основной поток приложения.

    Алгоритм параллельной загрузки

    Теперь перейдем к самому интересному — классу DownloadManager. Логика работы будет следующей:

  • Получаем размер файла.
  • Если сервер поддерживает докачку (Range), делим файл на частей.
  • Запускаем корутин, каждая из которых качает свой кусок во временный файл.
  • Ждем завершения всех корутин.
  • Склеиваем временные файлы в один итоговый.
  • Удаляем мусор.
  • !Визуализация потока данных: от разделения файла на сервере до сборки сегментов на локальном диске

    Реализация метода загрузки

    Создадим основной метод download. Для начала определимся с сигнатурой:

    Функция inputStream.copyTo(outputStream) — это удобный метод расширения в Kotlin, который перекачивает данные из одного потока в другой.

    Важно: Мы используем временные файлы (part_0.tmp, part_1.tmp и т.д.). Это простой и надежный способ. Он не требует сложной синхронизации доступа к одному файлу, как это было бы при использовании RandomAccessFile для записи в разные места одного файла одновременно. Каждый поток пишет в свой файл, никто никому не мешает.

    Сборка итогового файла

    Когда downloadTasks.awaitAll() вернет нам список временных файлов, это будет означать, что все части успешно скачаны. Если хотя бы одна загрузка упадет с ошибкой, awaitAll выбросит исключение, и мы не перейдем к этапу сборки (благодаря Structured Concurrency).

    Теперь нам нужно склеить файлы в правильном порядке.

    Почему это работает быстро?

    Давайте проанализируем, что происходит на уровне системы.

  • IO-Bound задача: Загрузка файла упирается в скорость сети, а не процессора. Процессор большую часть времени просто ждет прихода пакетов.
  • Non-blocking: Благодаря корутинам и Dispatchers.IO, мы можем запустить хоть 50 соединений. Потоки из пула IO будут обслуживать их, переключаясь между задачами, когда те ожидают данные.
  • Утилизация канала: Если сервер отдает данные со скоростью 1 Мбит/с на одно соединение, а ваш канал — 100 Мбит/с, то однопоточная загрузка займет лишь 1% вашего канала. Запустив 10 потоков, вы получите суммарную скорость 10 Мбит/с.
  • Заключение

    Сегодня мы реализовали ядро нашего менеджера загрузок. Мы научились:

    * Выполнять HEAD запросы для получения метаданных. * Использовать async и awaitAll для координации параллельных задач. * Работать с файловыми потоками для сохранения и объединения данных.

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

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

    4. Создание консольного интерфейса и визуализация прогресса загрузки через Flow

    Создание консольного интерфейса и визуализация прогресса загрузки через Flow

    Мы уже проделали огромную работу: спроектировали архитектуру, научились «пилить» файлы на части и скачивать их параллельно. Но пока наш менеджер загрузок похож на «черный ящик». Мы запускаем его, и в консоли тишина, пока файл внезапно не появляется на диске. Пользователь не знает, зависла программа или работает, сколько осталось времени и какая сейчас скорость.

    В этой статье мы оживим наше приложение. Мы создадим реактивный UI прямо в консоли, используя мощный инструмент Kotlin — Flow.

    Проблема молчаливого консольного приложения

    В графических интерфейсах (GUI) у нас есть Event Loop, который перерисовывает окно 60 раз в секунду. В консоли все иначе: это поток текста. Обычно мы просто пишем строку за строкой (println). Но если мы будем писать новую строку на каждый скачанный килобайт, консоль превратится в бесконечную простыню текста.

    Нам нужно научиться обновлять одну и ту же строку. Для этого используется специальный управляющий символ возврата каретки — \r (Carriage Return).

    Но прежде чем рисовать, нам нужно получить данные для отрисовки. И здесь нам поможет реактивное программирование.

    Знакомство с Kotlin Flow

    Наш процесс загрузки асинхронный и многопоточный. Несколько корутин одновременно качают данные. Нам нужен способ собирать информацию о прогрессе со всех потоков и передавать её в «центр управления» для отображения.

    Kotlin Coroutines предоставляют для этого идеальный инструмент — Flow (Поток).

    !Визуализация того, как данные о прогрессе из разных потоков стекаются в единый поток состояния Flow

    Flow — это асинхронный поток данных, который эмитирует (выпускает) значения последовательно. В отличие от List, который хранит все значения сразу, Flow выдает их по одному во времени.

    Для нашей задачи идеально подойдет MutableStateFlow. Это специальный вид Flow, который всегда хранит текущее состояние и уведомляет подписчиков, когда оно меняется.

    Модель состояния загрузки

    Сначала определим, что мы хотим показывать пользователю. Создадим Data Class для хранения состояния:

    Здесь мы используем простую формулу для вычисления процента:

    Где — прогресс в процентах, — количество скачанных байт (downloadedBytes), а — общий размер файла (totalBytes).

    Интеграция Flow в DownloadManager

    Нам нужно модифицировать наш DownloadManager, чтобы он не просто качал, но и сообщал о прогрессе.

    Главная сложность здесь — потокобезопасность. Так как несколько корутин будут обновлять счетчик скачанных байт одновременно, обычный Long или Int использовать нельзя — мы получим «гонку данных» (race condition) и неверные цифры. Нам понадобится AtomicLong.

    Обновление класса DownloadManager

    Добавим MutableStateFlow и AtomicLong:

    Теперь изменим метод downloadSegment. Раньше мы просто копировали поток в файл. Теперь нам нужно считать байты и обновлять счетчик.

    Обратите внимание: мы больше не используем inputStream.copyTo(outputStream), так как нам нужен ручной контроль над циклом чтения, чтобы считать прогресс.

    Создание консольного UI

    Теперь, когда DownloadManager транслирует данные, нам нужно их «слушать» и отображать. Вернемся в Main.kt.

    Сбор данных из Flow (collect) — это блокирующая (suspend) операция. Поэтому мы запустим её в отдельной корутине параллельно с процессом загрузки.

    Функция отрисовки прогресс-бара

    Напишем функцию, которая принимает состояние и рисует красивую строку.

    Связываем всё вместе в Main.kt

    Расчет скорости загрузки

    Прогресс-бар — это хорошо, но пользователь хочет знать скорость. Скорость — это количество байт, скачанных за единицу времени.

    Формула мгновенной скорости:

    Где — скорость (байт/сек), — изменение количества скачанных данных, — прошедшее время.

    Чтобы реализовать это, нам нужно хранить предыдущее значение байтов и времени. Однако, обновлять скорость на каждый байт — плохая идея (цифры будут мелькать). Лучше делать замер раз в секунду.

    Мы можем создать отдельный Flow для скорости или добавить логику в цикл отрисовки. Простейший вариант — таймер внутри UI-корутины, который раз в секунду считает разницу:

    Форматирование данных

    Сырые байты (10485760 bytes) читать неудобно. Давайте напишем утилиту для перевода в человекочитаемый вид (Human Readable Format).

    Теперь наш вывод в консоли будет выглядеть профессионально: [=================> ] 65% | 650 MB / 1.00 GB | 12.5 MB/s

    Резюме

    Сегодня мы сделали наше приложение интерактивным:

  • Использовали MutableStateFlow как единый источник правды о состоянии загрузки.
  • Применили AtomicLong для безопасного подсчета байтов из разных потоков.
  • Реализовали консольную анимацию с помощью символа возврата каретки \r.
  • Научились считать скорость и форматировать байты.
  • Теперь наш менеджер загрузок не только работает быстро, но и выглядит как серьезный инструмент. В следующей, заключительной статье курса, мы займемся обработкой ошибок, отменой загрузки (Graceful Shutdown) и финальной полировкой кода.

    5. Обработка сетевых ошибок, отмена операций и финальная сборка проекта

    Обработка сетевых ошибок, отмена операций и финальная сборка проекта

    Поздравляю! Мы добрались до финала. У нас есть работающий менеджер загрузок: он умеет определять размер файла, разбивать его на сегменты, скачивать их параллельно и даже показывать красивый прогресс-бар в консоли. Но есть одна проблема: наш код работает идеально только в «лабораторных условиях».

    В реальном мире интернет нестабилен, серверы разрывают соединения, а пользователи могут передумать и нажать Ctrl+C. Если мы не обработаем эти сценарии, наше приложение будет падать с пугающими стектрейсами или оставлять на диске «битые» файлы.

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

    Стратегия обработки ошибок: Retry Policy

    Сетевые ошибки — это норма, а не исключение. SocketTimeoutException или ConnectionResetException могут возникнуть в любой момент. Самая простая стратегия — попробовать снова.

    Однако, если сервер перегружен, мгновенный повтор запроса от тысячи клиентов только ухудшит ситуацию (это называется «шторм запросов»). Чтобы этого избежать, используется алгоритм Exponential Backoff (Экспоненциальная задержка).

    Математика задержки

    Суть метода проста: с каждой неудачной попыткой мы увеличиваем время ожидания перед следующей попыткой экспоненциально.

    Формула расчета времени ожидания выглядит так:

    Где: * — время ожидания перед следующей попыткой (в миллисекундах). * — базовое время ожидания (например, 1000 мс). * — номер текущей попытки повтора (начиная с 1). * — множитель (фактор роста).

    Например, если :

  • Первая ошибка (): ждем мс.
  • Вторая ошибка (): ждем мс.
  • Третья ошибка (): ждем мс.
  • Реализация Retry в Kotlin

    Давайте напишем универсальную функцию-обертку retryIO. Она будет принимать блок кода и пытаться выполнить его заданное количество раз.

    Важный момент: Корутины кооперативны. Это значит, что код должен проверять статус отмены. Сетевые вызовы в OkHttp и функции delay автоматически поддерживают отмену (выбрасывают CancellationException). Но если бы у нас был тяжелый вычислительный цикл, нам пришлось бы вручную проверять свойство isActive.

    Сборка проекта (Build)

    Мы писали код в IDE, но консольная утилита должна запускаться из терминала без IntelliJ IDEA. Нам нужно собрать Fat JAR (или Shadow JAR) — один файл .jar, который содержит и наш код, и все зависимости (OkHttp, Coroutines).

    Настройка Gradle

    Для этого добавим плагин application и shadow (популярный плагин для создания uber-jar) в build.gradle.kts.

    Теперь выполним команду в терминале:

    ./gradlew shadowJar

    В папке build/libs появится файл downloader-1.0.0.jar.

    Запуск

    Теперь вы можете отправить этот файл другу или запустить на другом компьютере (где есть Java):

    Итоги курса

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

    Давайте вспомним, чему мы научились:

  • Архитектура: Мы разделили приложение на слои (Network, Domain, UI), что позволило легко менять логику и тестировать код.
  • HTTP Range: Мы разобрались, как просить сервер отдавать файл кусочками, используя математику диапазонов.
  • Coroutines: Мы научились запускать тысячи легких потоков, использовать Dispatchers.IO для сети и async/await для параллелизма.
  • Flow: Мы создали реактивный UI, который обновляется в реальном времени, не блокируя основной поток.
  • Robustness: Мы добавили повторные попытки и корректное завершение работы.
  • Куда двигаться дальше?

    Ваш проект — отличная база. Вот идеи для развития, если вы захотите продолжить: Возобновление загрузки: Сохранять состояние загруженных частей на диск, чтобы после перезапуска программы продолжать с того же места (как в Internet Download Manager*). * Очередь загрузок: Скачивать не один файл, а список файлов последовательно или параллельно. * Ограничение скорости: Реализовать RateLimiter, чтобы программа не забивала весь интернет-канал.

    Системное программирование — это увлекательный мир. Надеюсь, этот курс дал вам уверенность в работе с сетью и многопоточностью в Kotlin. Удачи в ваших будущих проектах!