Содержание урока · 11 разделов
- Настройки кончились — начинаются файлы
- URI: адрес вместо пути
- RootURI: закрытая территория приложения
- Диалоги выбора: решение за пользователем
- Фильтры, каталоги и выбор папки
- Каталог файлового API
- Практика: что хранить файлами
- Что дальше: телефон в кармане
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Настройки кончились — начинаются файлы#
Хранилище настроек из девятнадцатого урока рассчитано на десятки мелких значений четырёх базовых типов: тема, громкость, счётчик запусков. Как только данные перестают быть мелкими — черновик редактора, отчёт для пользователя, журнал действий, скачанная картинка — нужен файл. Теоретически можно взять os.ReadFile из стандартной библиотеки, но путь к нему не выбрать: каталог, куда приложению разрешено писать, выделяет операционная система, и на каждой платформе он свой, а на мобильных путь вообще не существует как строка. Fyne решает это встроенным хранилищем, и запрос «fyne файлы» — это именно оно: myApp.Storage() возвращает fyne.Storage — интерфейс документов приложения с четырьмя рабочими методами. Create(name) создаёт файл в каталоге приложения и отдаёт fyne.URIWriteCloser для записи; Open(name) открывает существующий как fyne.URIReadCloser; List() перечисляет имена сохранённого; Remove(name) стирает файл по имени. Запрос «fyne storage» приводит к тому же интерфейсу. Имена — относительные, без слэшей и точек-родителей: хранилище само разыменует их внутри своего корня. Отличие от ресурсов шестнадцатого урока принципиальное: ресурсы вкомпилированы в бинарник и неизменяемы, а файлы живут на диске, меняются в любой момент и переживают перезапуск так же надёжно, как preferences, — только объём у них любой.
Черновик, который переживает перезапуск
GOpackage main
import (
"io"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.NewWithID("io.gomaster.notes")
window := myApp.NewWindow("Черновик")
editor := widget.NewMultiLineEntry()
editor.SetPlaceHolder("Текст сохраняется в хранилище приложения")
if r, err := myApp.Storage().Open("draft.txt"); err == nil {
data, _ := io.ReadAll(r)
r.Close()
editor.SetText(string(data))
}
save := widget.NewButton("Сохранить черновик", func() {
w, err := myApp.Storage().Create("draft.txt")
if err != nil {
return
}
w.Write([]byte(editor.Text))
w.Close()
})
window.SetContent(container.NewVBox(editor, save))
window.ShowAndRun()
}При старте Open пытается открыть прошлый черновик: первый запуск не находит файла и получает ошибку — это нормальный путь, а не сбой, поэтому проверка err == nil и дальше пустой editor. Кнопка записи берёт writer у Create, кладёт байты и закрывает. Идентификатор из app.NewWithID решает, в каком каталоге всё окажется.
Что вернёт myApp.Storage().Open("no-such.txt") при первом запуске приложения?
URI: адрес вместо пути#
Вся файловая часть Fyne говорит на fyne.URI — интерфейсе-адресе с тремя методами. String() возвращает полную строку вида file:///home/user/.local/share/io.gomaster.notes/draft.txt; Scheme() — только схему, для файлов это file; Extension() — расширение вместе с точкой: «.txt», «.csv». Запрос «file:// uri» — это он и есть. Внутри хранилища хватает имён, но как только файл выходит наружу — в диалогах этого урока, в canvas.NewImageFromURI из шестнадцатого, в фильтрах, — повсюду ходят URI. Построить адрес можно двумя функциями пакета storage: NewFileURI(путь) оборачивает обычный путь файла вне хранилища, NewURI(строка) разбирает готовый адрес обратно. Зачем интерфейс вместо строки пути? Во-первых, мобильные платформы двадцать второго урока: там файл пользователя — дескриптор с URI, и настоящего пути ты не увидишь никогда; код, написанный против интерфейса, переезжает туда без единой правки. Во-вторых, самодокументирование: Scheme и Extension читаются программой, фильтр диалога сравнивает uri.Extension() со своим списком, отладочная печать String() показывает, где данные на самом деле. Правило гигиены отсюда: не склеивай пути конкатенацией и не храни абсолютный путь в preferences из девятнадцатого — он привязан к машине и идентификатору; храни имя файла или строку URI и открывай через хранилище.
Схема и расширение
Прочитайте код и выберите, что он выведет:
u := storage.NewFileURI("/tmp/report.csv")
fmt.Println(u.Scheme(), "|", u.Extension())RootURI: закрытая территория приложения#
RootURI() возвращает URI каталога, который система выделила приложению по идентификатору: на Linux это внутри ~/.local/share, на macOS — Application Support, на Windows — %AppData%; те же места, где лежит preferences.json из девятнадцатого урока, только рядом. Каталог создаётся при первом обращении к хранилищу и принадлежит приложению: чужие программы в него не заглядывают, при удалении приложения система его сносит. Из этого два следствия. Первое — объём: пишите сколько нужно, кеш на сто мегабайт или черновик в три строки, хранилище не ограничивает; границы задаёт диск и здравый смысл, а не API. Второе — идентификатор решает всё: сменишь io.gomaster.notes на другое имя, и старые файлы окажутся в чужом каталоге — та же история, что с потерянными настройками в девятнадцатом, где именно из-за app.New() вместо app.NewWithID файл каждый раз оказывался новым. Перечень содержимого даёт List(): срез имён строками, по каждому работают Open и Remove. Для отладки полезно один раз напечатать myApp.Storage().RootURI().String() и глазами увидеть, где живут данные, — на десктопе этот каталог можно открыть в проводнике и посмотреть своими глазами.
Инвентаризация и очистка хранилища
GOpackage main
import (
"sort"
"strings"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.NewWithID("io.gomaster.notes")
window := myApp.NewWindow("Файлы приложения")
label := widget.NewLabel("")
refresh := func() {
names := myApp.Storage().List()
sort.Strings(names)
if len(names) == 0 {
label.SetText("Хранилище пусто")
} else {
label.SetText("Файлы:\n" + strings.Join(names, "\n"))
}
}
refresh()
clean := widget.NewButton("Удалить всё", func() {
for _, name := range myApp.Storage().List() {
myApp.Storage().Remove(name)
}
refresh()
})
window.SetContent(container.NewVBox(label, clean))
window.ShowAndRun()
}List возвращает имена строками, а не URI — сортируй и показывай как удобно; Remove стирает по имени и не спрашивает подтверждения, поэтому в настоящем приложении кнопку «Удалить всё» прячут за dialog.ShowConfirm из тринадцатого урока. Обновление подписи через локальную функцию refresh — то же правило одного источника правды, что и в списках десятого урока.
Что возвращает myApp.Storage().List()?
Диалоги выбора: решение за пользователем#
Хранилище приложения — твоя территория, но исходник лежит в загрузках пользователя, а отчёт уезжает в его документы. Для этого в пакете dialog из тринадцатого урока есть файловая пара: dialog.NewFileOpen(callback, window) и dialog.NewFileSave(callback, window); короткие обёртки ShowFileOpen и ShowFileSave делают то же без промежуточной переменной. Колбэк открытия получает пару (fyne.URIReadCloser, error): reader, из которого читай байты, и ошибку. Колбэк сохранения — (fyne.URIWriteCloser, error): writer для записи результата. Запрос «filedialog fyne» в поиске приводит именно к этой паре. Три факта, которые надо знать заранее. Первый: колбэк доставляет главный поток — тулкит позвал тебя из своего цикла, поэтому менять интерфейс можно напрямую, без fyne.Do из двадцатого урока. Второй: отмена — это nil и nil: пользователь закрыл диалог, ошибки нет, а reader nil; проверяй на nil всегда, иначе первая же отмена кончится паникой на Write. Третий: чтение большого файла внутри колбэка заморозит окно — уноси в горутину и возвращайся с готовой строкой через fyne.Do, как загрузчик из двадцатого.
Открыть текст и не заморозить окно
GOpackage main
import (
"io"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.NewWithID("io.gomaster.notes")
window := myApp.NewWindow("Импорт")
status := widget.NewLabel("Файл не выбран")
editor := widget.NewMultiLineEntry()
open := widget.NewButton("Открыть текст", func() {
fd := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
if err != nil {
dialog.ShowError(err, window)
return
}
if r == nil {
return // пользователь отменил выбор
}
label := r.Uri().String()
go func() {
data, readErr := io.ReadAll(r)
r.Close()
fyne.Do(func() {
if readErr != nil {
status.SetText("Ошибка чтения")
return
}
editor.SetText(string(data))
status.SetText("Загружен: " + label)
})
}()
}, window)
fd.SetFilter(storage.NewExtensionFileFilter([]string{".txt", ".md"}))
fd.Resize(fyne.NewSize(600, 400))
fd.Show()
})
window.SetContent(container.NewVBox(status, editor, open))
window.ShowAndRun()
}Диалог собирается с фильтром: покажет только .txt и .md — сравнение идёт с точкой по uri.Extension(). Проверки идут по порядку: err — ошибка выбора, nil — отмена. Дальше канон двадцатого урока в действии: чтение уезжает в горутину, а в главный поток возвращается один fyne.Do с готовой строкой. Адрес файла для подписи берётся у r.Uri() до горутины — читаемое значение зафиксировано, в замыкание уезжает готовая строка.
Где исполняется колбэк файлового диалога?
Фильтры, каталоги и выбор папки#
Фильтр задаётся SetFilter с любым fyne.FileFilter; стандартный storage.NewExtensionFileFilter принимает список расширений с точками и прячет всё остальное — для бытовых задач этого достаточно. Стартовый каталог — SetLocation(uri): сюда передают fyne.ListableURI, адрес каталога, у которого можно спрашивать содержимое методом List() ([]fyne.URI, error). Не всякий URI перечислим, поэтому перед передачей проверь storage.CanList и приведи тип: lu, ok := uri.(fyne.ListableURI). Откуда взять первый ListableURI? Из хранилища: root := myApp.Storage().RootURI() после проверки CanList приводится и диалог откроется прямо в территории приложения; запомни выбранный пользователем каталог как URI — в следующий раз SetLocation предложит то же место, и пользователь скажет спасибо. Отдельный сценарий — выбор папки целиком: dialog.NewFolderOpen(callback, window) с колбэком (fyne.ListableURI, error); получил каталог — перебирай List() и импортируй пачку файлов. Сохранению нужны свои настройки: SetFileName("otchet.csv") подсказывает имя, пользователь его поправит перед записью. И Resize(fyne.NewSize(640, 480)) — на экранах с крупным масштабом из пятнадцатого урока системный диалог по умолчанию бывает обидно маленьким.
Отмена выбора
Прочитайте код и выберите, что он выведет:
dialog.ShowFileSave(func(w fyne.URIWriteCloser, err error) {
fmt.Println(w == nil, err == nil)
}, window)
// пользователь закрыл диалог кнопкой «Отмена»Экспорт отчёта
GOpackage main
import (
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.NewWithID("io.gomaster.notes")
window := myApp.NewWindow("Экспорт")
export := widget.NewButton("Экспорт отчёта", func() {
fd := dialog.NewFileSave(func(w fyne.URIWriteCloser, err error) {
if err != nil {
dialog.ShowError(err, window)
return
}
if w == nil {
return // отмена
}
if _, writeErr := w.Write([]byte("все заметки, строка за строкой\n")); writeErr != nil {
dialog.ShowError(writeErr, window)
return
}
address := w.Uri().String()
w.Close()
dialog.ShowInformation("Готово", "Отчёт сохранён: "+address, window)
}, window)
fd.SetFileName("otchet-" + time.Now().Format("2006-01-02") + ".txt")
fd.Show()
})
window.SetContent(container.NewVBox(export))
window.ShowAndRun()
}SetFileName подсказывает осмысленное имя с датой вместо пустого поля. Проверки по порядку: err, nil, ошибка записи — каждая ошибка показывается через ShowError из тринадцатого урока, успех — через ShowInformation. Адрес сохранённого файла достаётся у w.Uri() и попадает в отчёт пользователю: он видит не абстрактное «готово», а куда именно лёг файл.
Каталог файлового API#
| API | Что делает | Когда звать |
|---|---|---|
| myApp.Storage() | получить хранилище приложения | один раз при старте, дальше держи ссылку |
| Storage.RootURI() | URI каталога приложения | стартовый каталог диалогов, отладка |
| Storage.Create(name) | создать файл, отдать fyne.URIWriteCloser | сохранение данных приложения |
| Storage.Open(name) | открыть файл, отдать fyne.URIReadCloser | загрузка при старте с проверкой err |
| Storage.List() | срез имён файлов строками | экран «мои документы», автосохранение |
| Storage.Remove(name) | удалить файл по имени | очистка, сброс данных |
| storage.NewFileURI(путь) | обернуть внешний путь в fyne.URI | файлы вне хранилища |
| storage.NewURI(строка) | разобрать готовый адрес | URI хранится как строка в настройках |
| URI.Scheme() / Extension() / String() | схема, расширение с точкой, полный адрес | фильтры, ветвления, отладка |
| dialog.NewFileOpen / ShowFileOpen | диалог открытия файла | импорт данных пользователя |
| dialog.NewFileSave / ShowFileSave | диалог сохранения файла | экспорт отчётов |
| dialog.NewFolderOpen | диалог выбора папки | пакетный импорт каталога |
| SetFilter + storage.NewExtensionFileFilter | ограничить расширения | «только .txt и .md» |
| SetLocation / SetFileName / Resize | настройка диалога | стартовый каталог, имя, размер окна |
Собери сохранение и чтение
Восстанови пять пропусков: хранилище, создание файла, освобождение writer, диалог открытия и его фильтр.
Путь экспорта от кнопки до диска
Расставь по порядку, что происходит, когда пользователь экспортирует отчёт из приложения.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Файловому API нужен настоящий экран и настоящий диск — в браузерной песочнице курса GUI не исполняется. Собери блокнот по второму уроку и проверь главный сценарий: сохранённый черновик возвращается после перезапуска, а печать RootURI().String() покажет, где именно приложение хранит твои данные.
Каждый открытый reader и writer — ресурс операционной системы: за Open и Create обязан следовать Close, удобнее всего сразу defer. И никогда не сохраняй абсолютный путь в preferences из девятнадцатого урока: путь мёртв при смене платформы и идентификатора. Храни имя файла или строку URI — они переезжают между машинами живыми.
Практика: что хранить файлами#
Черновики и документы: текст редактора больше пары килобайт — файл draft.txt, а не ключ в preferences; автосохранение по таймеру — готовый тикер из двадцатого урока, интервал — Int-ключ в настройках. Экспорт и импорт: отчёты пользователя через ShowFileSave с SetFileName, входные данные через ShowFileOpen с фильтром по нужным расширениям. Кеш изображений: выбранную в диалоге картинку можно сразу показать — canvas.NewImageFromURI из шестнадцатого урока принимает тот же адрес, а тяжёлую обработку уноси в горутину по канону двадцатого. Журналы: один файл-история вместо сотен мелких файлов — иначе List() превратится в свалку, в которой ничего не найти. Что хранить файлами НЕ нужно: настройки — их место в preferences из девятнадцатого; иконки и шрифты, вкомпилированные при сборке, — их место в ресурсах шестнадцатого; общие документы пользователя — их показывают диалогами и читают по URI, а не копируют к себе. Именуй файлы по делу: draft.txt, export-2024-05-01.csv, cache/avatars — имена это твой интерфейс к List(). И помни про совместимость: формат файла меняется — старую версию читай терпеливо и конвертируй при загрузке, данные пользователя дороже красоты кода.
Что дальше: телефон в кармане#
Теперь приложение владеет данными: черновик живёт в RootURI, экспорт уезжает через диалог с фильтром, импорт читается в фоне без заморозки окна. Опора урока — девятнадцатый с границей «настройки против данных» и StoragePath, двадцатый с каноном «колбэки — в главном потоке, чтение — в горутину, возврат — через fyne.Do», тринадцатый с семейством dialog и диалогами ошибок, шестнадцатый с canvas.NewImageFromURI, девятый с многострочным полем. Следующая тема — сборка для мобильных: тот же код, fyne package -os android, палец вместо мыши, и файловый диалог из этого урока превращается в системный пикер. Полный маршрут — на странице курса Fyne.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный экран
HTML-модель виджет-дерева fynedraft.txt
app.Storage() → fyne.Storage
Open/Create возвращают URIReadCloser и URIWriteCloser
Экран собран: «Файлы и диалоги»
Коротко: что запомнить
- myApp.Storage() — хранилище документов приложения: Create и Open с проверкой err, List с именами строками, Remove по имени.
- Файлы начинаются там, где preferences заканчиваются: черновики, отчёты, кеш и журналы — данные, а не настройки.
- Всюду fyne.URI: String() даёт адрес, Scheme() — file, Extension() — расширение с точкой; внешние пути оборачивает storage.NewFileURI.
- RootURI() — закрытый каталог по идентификатору: объём не ограничен, смена ID означает другой каталог.
- Файловые диалоги живут в пакете dialog: NewFileOpen и NewFileSave, фильтр NewExtensionFileFilter, SetFileName, SetLocation, Resize.
- Колбэк диалога приходит в главный поток; отмена — (nil, nil): сначала err, потом nil, и только потом работа с файлом.
- Большой файл читай в горутине и возвращайся через fyne.Do — канон двадцатого урока; за Open и Create всегда следует Close.
Домашнее задание
Собери «Мой документы» целиком. Блокнот с многострочным Entry из девятого урока, черновик которого живёт в draft.txt через Storage().Create и Open с проверкой err, автосохранение — тикер из двадцатого урока раз в минуту. Добавь импорт: ShowFileOpen с фильтром «.txt» и «.md», чтение файла в горутине и возврат через fyne.Do, подпись со строкой r.Uri(). Добавь экспорт: ShowFileSave с SetFileName вида «notes-дата.txt», запись с проверками err, nil и ошибки записи, отчёт об успехе через ShowInformation из тринадцатого урока. Внизу — экран файлов: List(), отсортированные имена в List из десятого урока и кнопка удаления выбранного через Remove, спрятанная за ShowConfirm. Стартовый каталог диалога задай SetLocation из RootURI после проверки storage.CanList. Ловушка для самопроверки: отмени диалог посреди экспорта и убедись, что приложение не упало; попробуй сохранить путь к файлу в настройку, сменить идентификатор приложения и понять, почему путь умер, а имя и строка URI — живы.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли работать с файлами напрямую через os и filepath?
Технически да, но путь приложения не выбираешь: каталог выделяет система, и на мобильных платформах пути как строки вообще нет — есть URI. Хранилище абстрагирует это: один код работает и на десктопе, и в изолированном каталоге телефона. os.* оставь для системных задач вне данных приложения.
Почему после отмены диалога колбэк всё равно вызывается?
Так устроен контракт: колбэк — единственная точка продолжения, и отмена обязана через неё пройти. Поэтому отмена приходит как (nil, nil), а не как отсутствие вызова. Всегда проверяй сначала err, затем reader или writer на nil — и только потом работай с файлом.
Где физически лежат файлы, сохранённые через хранилище?
В каталоге приложения, построенном из идентификатора: RootURI().String() напечатает точное место. Путь зависит от платформы, поэтому не хардкодь его — адресуй файлы именами через Create и Open, а полный адрес получай от URI в рантайме.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Cobra: CLI-приложения на Go
Интерактивный курс Cobra — фреймворка для CLI на Go: команды, флаги, конфиги Viper, автодополнение. 25 уроков с примерами кода, тестами и итоговым приложением.
- Создавать CLI-приложения с деревом команд на Cobra
- Работать с аргументами, флагами и их привязкой к конфигу
- Управлять конфигурацией через Viper: файлы, env, приоритеты
- Писать хуки PreRun/PostRun и корректно обрабатывать ошибки