Зачем программе файлы и даты
Программа редко живёт одним запуском. Ей нужно сохранить результат работы на диск, чтобы прочитать его позже: список задач, настройки, журнал событий. И почти в любом журнале рядом с событием стоит время, когда оно произошло. Сегодня разберём, как Swift работает с файлами на диске и как превращать момент времени в понятную человеку строку.
FileManager и временные файлы
За работу с файловой системой отвечает тип FileManager. У него есть общий экземпляр FileManager.default, через который можно создавать, читать, перемещать и удалять файлы и папки.
В песочнице курса, да и вообще в большинстве серверных программ, нельзя писать куда угодно на диске — только в специально отведённую папку для временных файлов. Swift даёт её адрес напрямую:
let folder = FileManager.default.temporaryDirectory
let fileURL = folder.appendingPathComponent("report.txt")
print(fileURL.path)
Здесь temporaryDirectory возвращает URL — адрес папки. Метод appendingPathComponent достраивает к нему имя файла, не трогая исходный URL (он тоже структура, копируется как значение).
Записать текст в файл и прочитать его обратно можно двумя методами String:
let text = "Первая строка\nВторая строка"
try text.write(to: fileURL, atomically: true, encoding: .utf8)
let loaded = try String(contentsOf: fileURL, encoding: .utf8)
print(loaded)
Оба метода помечены throws: запись и чтение могут провалиться (нет прав, диск полон, файла не существует), поэтому вызывать их нужно через try внутри do/catch или похожей конструкции. Параметр atomically: true значит, что Swift сначала пишет во временный файл рядом и только потом подменяет им целевой — если что-то пойдёт не так посреди записи, старое содержимое не испортится.
Когда временный файл больше не нужен, его стоит убрать за собой:
try? FileManager.default.removeItem(at: fileURL)
try? превращает ошибку в nil — если файла и так уже нет, программа не упадёт.
Date — точка на числовой прямой
Тип Date хранит момент времени как число секунд относительно 1 января 1970 года (это называется Unix-эпоха). Внутри Date нет ни часового пояса, ни языка — это просто точка на числовой прямой, и создать её можно явно:
let launch = Date(timeIntervalSince1970: 1_700_000_000)
print(launch.timeIntervalSince1970)
Сравнивать даты можно как обычные значения: <, >, ==. Разницу между двумя моментами возвращает timeIntervalSince(_:) в виде количества секунд:
let start = Date(timeIntervalSince1970: 1_700_000_000)
let end = Date(timeIntervalSince1970: 1_700_003_600)
let seconds = end.timeIntervalSince(start)
print(seconds) // 3600.0
Чтобы достать из даты год, месяц, час и так далее, используют Calendar — он знает, как устроен календарь (сколько дней в месяце, что такое високосный год) и в каком часовом поясе считать:
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
let parts = calendar.dateComponents([.year, .month, .day, .hour], from: launch)
print(parts.year!, parts.month!, parts.day!, parts.hour!)
dateComponents возвращает структуру DateComponents, где каждое поле — опционал: если вы не запросили какую-то часть в первом параметре, соответствующее свойство будет nil.
formatted() и ловушка с локалью
Показывать пользователю секунды с 1970 года бессмысленно — нужна читаемая строка вроде «14 ноября 2023». Для этого у Date есть метод formatted():
let now = Date(timeIntervalSince1970: 1_700_000_000)
print(now.formatted(date: .long, time: .shortened))
Это самый короткий способ, но у него есть важная особенность: без явных настроек formatted() берёт язык и часовой пояс из системных настроек устройства, на котором выполняется код — это называется текущая локаль, Locale.current. Один и тот же Date превратится в разные строки на телефоне с русским языком и на сервере, настроенном на английский. Для интерфейса это как раз то, что нужно — пользователь видит дату на своём языке. А вот для журналов, тестов и всего, что должно выглядеть одинаково независимо от того, где выполняется код, такая зависимость от окружения — источник трудноуловимых ошибок.
Решение — задавать локаль и часовой пояс явно через Date.FormatStyle:
let style = Date.FormatStyle(
locale: Locale(identifier: "en_US_POSIX"),
timeZone: TimeZone(identifier: "UTC")!
).year().month(.twoDigits).day(.twoDigits)
print(now.formatted(style))
en_US_POSIX — специальная локаль, которая не меняется от настроек устройства и не подстраивается под региональные соглашения, поэтому её обычно берут именно для машинного, а не человеческого формата. Когда нужен полный контроль над видом строки (например «14.11.2023 22:13» без вариантов), проще и надёжнее собрать её самим из компонентов календаря, как в примере с Calendar выше, и оформить через String(format:).
Частые ошибки
- Писать файл не во временную папку, а по произвольному пути — в песочнице и во многих реальных окружениях диск доступен только для чтения, кроме отведённой директории.
- Забывать удалить временный файл после использования — он остаётся лежать на диске до следующей перезагрузки системы.
- Полагаться на
formatted()без явной локали там, где важен одинаковый результат: в логах, в тестах, в данных для другой программы. - Путать
Dateс готовой строкой:Date— это момент времени, строку из него нужно получить отдельным шагом форматирования. - Не оборачивать
try text.write(...)иtry String(contentsOf:...)в обработку ошибок — при отсутствии прав или битом пути программа просто упадёт.
Резюме
FileManager.default.temporaryDirectory— единственная папка, куда точно можно писать.String.write(to:atomically:encoding:)иString(contentsOf:encoding:)— запись и чтение текста, оба варианта бросают ошибку.Dateхранит момент времени как число секунд от 1970 года, без языка и часового пояса.Calendarдостаёт из даты год, месяц, день и другие компоненты с учётом заданного часового пояса.formatted()удобен для интерфейса, но зависит от локали устройства — для предсказуемого результата задавайте локаль и часовой пояс явно.
Проверьте себя
3 вопроса
Журнал событий на диске
Реализуйте функцию makeEventLog(events:). Она принимает массив событий, каждое из которых — кортеж с именем name и меткой времени timestamp (число секунд от 1 января 1970 года, тип Double).
Для каждого события нужно:
- получить
DateизtimestampчерезDate(timeIntervalSince1970:); - достать из даты год, месяц, день, час и минуту с помощью
Calendarс явно заданным часовым поясом UTC (TimeZone(identifier: "UTC")!); - собрать строку вида «день.месяц.год час:минута», где день, месяц, год, час и минута дополнены нулями слева до двух (год — до четырёх) цифр, например «14.11.2023 22:13»;
- сформировать строку события как «имя - штамп», например «Старт - 14.11.2023 22:13».
Все строки событий нужно соединить символом перевода строки \n (для пустого массива событий результат — пустая строка), записать во временный файл через FileManager.default.temporaryDirectory, тут же прочитать файл обратно и вернуть прочитанное содержимое как результат функции. В конце обязательно удалите созданный временный файл — не оставляйте его на диске. Если запись или чтение вернут ошибку, функция должна вернуть пустую строку и всё равно попытаться удалить файл.