Хочешь уверенно проходить IT-интервью?
![Готовься к IT-собеседованиям уверенно с AI-тренажёром T1!](https://media.proglib.io/banner/2025/01/28/t1.jpg)
Мы понимаем, как сложно подготовиться: стресс, алгоритмы, вопросы, от которых голова идёт кругом. Но с AI тренажёром всё гораздо проще.
💡 Почему Т1 тренажёр — это мастхэв?
- Получишь настоящую обратную связь: где затык, что подтянуть и как стать лучше
- Научишься не только решать задачи, но и объяснять своё решение так, чтобы интервьюер сказал: "Вау!".
- Освоишь все этапы собеседования, от вопросов по алгоритмам до диалога о твоих целях.
Зачем листать миллион туториалов? Просто зайди в Т1 тренажёр, потренируйся и уверенно удиви интервьюеров. Мы не обещаем лёгкой прогулки, но обещаем, что будешь готов!
Реклама. ООО «Смарт Гико», ИНН 7743264341. Erid 2VtzqwP8vqy
В этой статье мы рассмотрим все тонкости создания Proof of Concept (PoC) мобильного приложения, построенного с помощью фреймворка SwiftUI и бэкенда с использованием FastAPI. Дополнительно я продемонстрирую эффективные архитектурные паттерны для SwiftUI-приложений, в частности MVVMP в сочетании с принципами SOLID и Dependency Injection (DI). Для android код можно легко перевести на Kotlin с помощью Jetpack Compose Framework.
Зачем нам нужен бэкенд
Кто-то может сказать, что можно просто запихнуть всю логику в приложение, напрямую отправлять запросы в chatgpt и сделать приложение без бэкенда. И я согласен, это действительно возможно, но бэкенд дает несколько важных преимуществ.
Бэкенд служит основой для любого сложного приложения, особенно для тех, которые требуют безопасного управления данными, обработки бизнес-логики и интеграции сервисов. Вот почему надежный бэкэнд имеет решающее значение:
- Безопасность: Бэкэнд помогает защитить конфиденциальные данные и токены аутентификации пользователей от атак типа MITM (Man-in-the-Middle). Он выступает в качестве защищенного шлюза между пользовательским устройством и базой данных или внешними службами, обеспечивая шифрование и аутентификацию всех данных.
- Контроль над использованием сервисов: Управляя API и взаимодействием с пользователями через бэкэнд, вы можете отслеживать и контролировать использование приложения. Это включает в себя дросселирование для управления нагрузкой, предотвращение злоупотреблений и обеспечение эффективного использования ресурсов.
- Интеграция с базой данных: Бэкэнд обеспечивает бесшовную интеграцию с базами данных, позволяя динамически хранить, извлекать и обновлять данные в режиме реального времени. Это важно для приложений, которые требуют учетных записей пользователей, хранят их предпочтения или нуждаются в быстром и безопасном получении больших объемов данных.
- Модели подписки и Freemium: Реализация услуг по подписке или модели freemium требует наличия бэкенда для выставления счетов, отслеживания использования и управления уровнями пользователей. Бэкэнд может безопасно обрабатывать платежи и подписки, обеспечивая бесперебойную работу пользователей и соблюдая требования по защите данных.
- Масштабируемость и обслуживание: Бэкэнд позволяет более эффективно масштабировать приложение. Логику на стороне сервера можно обновлять без необходимости передавать обновления клиенту, что упрощает обслуживание и ускоряет внедрение новых функций.
По сути, бэкенд — это не только функциональность, но и создание безопасной, масштабируемой и устойчивой среды для процветания вашего приложения.
Объяснение технического стека
- SwiftUI: Лучший вариант для нативных приложений для iOS после выхода UIKit. Он декларативен и упорядочен, а XCode является незаменимым редактором благодаря эпл. Для android код можно легко перевести на Kotlin с помощью Jetpack Compose.
- FastAPI: Выбран для бэкенда за его скорость, минимальное количество шаблонов и декларативность, редактируется с помощью превосходного Zed.dev.
- ChatGPT API: Используется в качестве большой языковой модели (LLM); выбор может меняться в зависимости от необходимости кастомизации.
- Ngrok: Реализует туннелирование с помощью простой команды CLI для выхода локального сервера в интернет.
Создание приложения для iOS
Теория: Архитектурные паттерны
1. MVVMP (Model View ViewModel Presenter):
- Model: Представляет собой структуры данных, используемые в приложении, такие как Question, Answer, Questionary и FilledQuestionary. Эти модели просты и содержат только данные, следуя принципу KISS.
- View: Отвечают только за представление пользовательского интерфейса и делегируют все данные и логику презентерам. Они не содержат никакой бизнес-логики и спроектированы так, чтобы быть простыми и сосредоточенными на рендере UI.
- ViewModel: В SwiftUI ViewModel представлена объектом ObservableObject, который служит моделью наблюдения за изменяемыми данными. Здесь нет методов и логики.
- Presenter: Presenter управляет всей логикой, связанной с модулем (экраном или представлением), но не бизнес-логикой. Он взаимодействует с доменным слоем для выполнения операций бизнес-логики, таких как взаимодействие с API или управление сохранением данных.
- Domain Layer: Этот слой содержит бизнес-логику приложения и взаимодействует с внешними ресурсами, такими как базы данных, API или другие сервисы. Он состоит из нескольких компонентов, таких как сервисы, провайдеры, менеджеры, репозитории, мапперы, фабрики и т. д.
- На самом деле, MP в MVVMP является инициалами Марка Паркера, а полная форма — «Model View ViewModel by Mark Parker».
2. Принципы SOLID:
- Принцип единой ответственности: У каждого класса должна быть только одна причина для изменений.
- Принцип открытость-закрытость: Компоненты должны быть открыты для расширения, но закрыты для модификации.
- Принцип замещения Лискова: Объекты суперкласса должны быть заменяемы объектами подклассов.
- Принцип разделения интерфейсов: Ни один клиент не должен быть вынужден зависеть от интерфейсов, которые он не использует.
- Принцип инверсии зависимостей: Зависимость от абстракций, а не от конкретики, чему способствует DI.
3. Инъекция зависимостей (DI):
- При использовании паттерна Dependency Injection зависимости предоставляются извне класса, а не инстанцируются внутри него. Это способствует развязке и позволяет упростить поддержку и тестирование кода.
Разработка бэкенда
Код бэкенда довольно прост. Эндпоинты (main.py):
from typing import Callable
import json
from fastapi import FastAPI, Body, Request, Response
from .models import (Question, FilledQuestionary, DoctorResponseAnswer, DoctorResponseQuestionary)
from .user_card import UserCardSimple
from .prompting import get_response
@app.get("/onboarding", response_model = DoctorResponseQuestionary)
def onboarding():
return DoctorResponseQuestionary(question=[Question(text=text) for text in UserCardSimple.__fields__.keys()])
@app.post("/doctor")
def doctor(user_card: UserCardSimple, filled_questionary: FilledQuestionary, message: str = Body(...)):
json_string = get_response(user_card, message, filled_questionary)
loaded = json.loads(json_string.strip())
return loaded
"onboarding" предоставляет список вопросов анамнеза, которые необходимо заполнить при первом запуске приложения. Ответы будут сохранены на устройстве и использованы для персонализированной диагностики в будущем. "doctor" — основной эндпоинт: он генерирует вопросы на основе предыдущих ответов и карты пользователя, либо возвращает результат диагностики.
Модели:
from pydantic import BaseModel
class Question(BaseModel):
text: str
class FilledQuestionary(BaseModel):
filled_questions: dict[str, str]
class DoctorResponseAnswer(BaseModel):
text: str
class DoctorResponseQuestionary(BaseModel):
questions: list[str]
class UserCardSimple(BaseModel):
sex: str
age: int
weight: int
height: int
special_conditions: str
Промпты:
import os
from openai import OpenAI
from .models import FilledQuestionary
api_key = os.environ.get("API_KEY")
client = OpenAI(api_key=api_key)
def get_response(user_card: str, message: str, filled_questionary: FilledQuestionary, max_tokens=200):
format_question = """{"questions":[{"text":"first question"},{"text":"second question"}]}"""
format_advice = """{"text":"Advice: Drink more water"}"""
system_prompt = f"""
You are a doctor that gives user an opportunity to swiftly check up health and diagnos an illness using anamnes and a short questionary.
Your task is to ask short questions and give your opinion and advices.
Your questions are accamulated in the filled questionary, which is empty in the first itteration.
Strive to about 1-2 questions per iteration and up to 6 questions in total (can be less). Questions must be short, clear, shouldn't repeat,
and should be relevant to the user's health condition, and should require easy answers.
Ask questions only in the json format {format_question}.
Number of answered questions: {len(filled_questionary.filled_questions)}
If the Number of answered questions is more then 6, you should stop asking questions an`d provide an give your final opinion,
an assumption or an advice in the json format {format_advice}.
"""
prompt = f"""request message: {message}; anamnesis: {user_card}; filled questionary: {filled_questionary};"""
chat_completion = client.chat.completions.create(
messages=[
{
"role": "system",
"content": f"{system_prompt}",
},
{
"role": "user",
"content": f"{prompt}",
},
],
model="gpt-3.5-turbo",
max_tokens=max_tokens
)
return chat_completion.choices[0].message.content
Модуль промптов использует GPT-3.5 от OpenAI для генерации ответов на основе пользовательского ввода, анамнеза и заполненных анкет. Он возвращает пользователю соответствующие вопросы и советы по диагностике здоровья. Как видите, ничего сложного здесь нет. Код элементарен, а промпты – просто набор четких инструкций для LLM.
Настройте окружение и запустите сервер с помощью fastapi dev main.py
.
Подробности:
- fastapi.tiangolo.com/tutorial/first-steps
- pypi.org/project/openai/
Открытие доступа к локальному хосту через Интернет
- Зарегистрируйтесь на сайте ngrok.com и получите токен доступа.
- Установите ngrok с сайта ngrok.com/download.
- Выполните команду
ngrok config add-authtoken <TOKEN>
. - Запустите с помощью команды
ngrok http http://localhost:8080
(при необходимости измените порт).
Подробные инструкции по настройке можно найти в документации ngrok.
Кодим приложение
Я не буду показывать здесь весь исходный код, для этого есть GitHub. Найти его можно по адресу: HouseMDAI iOS App. Вместо этого я остановлюсь только на важных (IMO) моментах.
Начнем с краткого описания задачи: нам нужно приложение с текстовым полем на главном экране, возможностью задавать набор динамических вопросов и показывать ответ. Также нам нужен одноразовый онбординг. Итак, приступим к коду.
Первым делом нам нужны модели, и они довольно просты (принцип KISS).
struct Question {
var text: String
}
struct Answer {
var text: String
}
struct Questionary {
var questions: [Question]
}
struct FilledQuestionary {
var filledQuestions: [String: String]
}
Теперь давайте сделаем онбординг. Продолжаем следовать KISS и SRP (Single Responsibility Principle), никакой бизнес-логики в представлениях, только UI. В данном случае – только список вопросов с прокруткой. Все данные и логика делегированы презентеру. Единственное, что здесь интересно, это небольшой вспомогательный метод bindingForQuestion
, который, вероятно, должен быть в презентере, но сейчас это не имеет значения.
import SwiftUI
struct OnboardingView: View {
@StateObject var presenter: OnboardingPresenter
var body: some View {
ScrollView {
Spacer()
VStack {
ForEach(presenter.questions.questions) { question in
VStack {
Text(question.text)
TextField("", text: bindingForQuestion(question))
.formItem()
}
.padding()
}
}.padding()
Button("Save", action: presenter.save)
Spacer()
}
}
private func bindingForQuestion(_ question: Question) -> Binding<String> {
Binding(
get: { presenter.answers.filledQuestions[question.text] ?? "" },
set: { presenter.answers.filledQuestions[question.text] = $0 }
)
}
}
Вы будете удивлены, но в презентере также нет никакой бизнес-логики!
class OnboardingPresenter: ObservableObject {
@Published public var answers: FilledQuestionary
private(set) public var questions: Questionary
private var completion: (FilledQuestionary) -> Void
init(questions: Questionary, answers: FilledQuestionary, completion: @escaping (FilledQuestionary) -> Void) {
self.questions = questions
self.answers = answers
self.completion = completion
}
func save() {
completion(answers)
}
}
Все по-прежнему simple, stupid и имеет только одну ответственность. Presenter должен содержать только логику своего представления. Бизнес-логика уровня приложения находится вне его юрисдикции, поэтому презентер просто делегирует ее наверх по стэку вызова.
Также можно заметить, что и View, и Presenter не инстанцируют ни одну из зависимостей, а получают их в качестве параметров при инициализации. Это соответствует принципу инверсии зависимостей, согласно которому модули высокого уровня не должны зависеть от модулей низкого уровня, но оба должны зависеть от абстракций. Это обеспечивает гибкость и упрощает тестирование, а также позволяет легко заменять зависимости или внедрять макеты для целей тестирования.
При использовании паттерна Dependency Injection зависимости предоставляются извне класса, а не инстанцируются внутри него. Это способствует развязке и позволяет упростить поддержку и тестирование кода.
Хотя в данном примере протоколы не используются явно, стоит отметить, что протоколы могут играть важную роль в коде, особенно для абстрагирования и облегчения тестирования. Определив протоколы для представлений, презентаторов и зависимостей, становится проще заменять реализации или предоставлять моки во время тестирования.
Итак, если презентер не содержит бизнес-логику, то где же она? Это задача для доменного слоя, который обычно содержит сервисы, провайдеры и менеджеры. У них всех очень схожее применение, и разница между ними до сих пор является предметом дискуссий. Давайте создадим OnboardingProvider
, который будет содержать всю бизнес-логику процесса онбординга.
class OnboardingProvider: ObservableObject {
init() {
loadFilledOnboardingFromDefaults()
}
// MARK: Interface
@Published private(set) var needsOnboarding: Bool = true
private(set) var filledOnboarding: FilledQuestionary? {
didSet {
if let filledOnboarding {
saveFilledOnboardingToDefaults(filledQuestionary: filledOnboarding)
}
}
}
func getOnboardingQuestionary() -> Questionary {
// NOTE: it's better to take the questions from the backend
Questionary(questions: [
Question(text: "sex"),
Question(text: "age"),
Question(text: "weight"),
Question(text: "height"),
Question(text: "special_conditions"),
])
}
func saveOnboardingAnswers(filledQuestionary: FilledQuestionary) {
needsOnboarding = false
filledOnboarding = filledQuestionary
}
// MARK: - Private
private func saveFilledOnboardingToDefaults(filledQuestionary: FilledQuestionary) {
UserDefaults.standard.removeObject(forKey: "filledOnboarding")
let encoder = JSONEncoder()
let encoded = try! encoder.encode(filledQuestionary)
UserDefaults.standard.set(encoded, forKey: "filledOnboarding")
}
private func loadFilledOnboardingFromDefaults() {
guard let object = UserDefaults.standard.object(forKey: "filledOnboarding") else {
needsOnboarding = true
return
}
let savedFilledQuestionary = object as! Data
let decoder = JSONDecoder()
let loadedQuestionary = try! decoder.decode(FilledQuestionary.self, from: savedFilledQuestionary)
self.filledOnboarding = loadedQuestionary
self.needsOnboarding = false
}
}
Опять же, он выполняет только одну задачу: управление бизнес-логикой процесса onboarding. Такая инкапсуляция позволяет другим классам взаимодействовать с ним без необходимости беспокоиться о деталях его внутренней реализации, что способствует созданию более чистой и удобной кодовой базы.
Теперь давайте соберем все вместе в корне приложения.
import SwiftUI
@main
struct HouseMDAI: App {
@StateObject private var onboardingProvider: OnboardingProvider
@StateObject private var onboardingPresenter: OnboardingPresenter
@StateObject private var homePresenter: HomePresenter
init() {
let onboardingProvider = OnboardingProvider()
let onboardingPresenter = OnboardingPresenter(
questions: onboardingProvider.getOnboardingQuestionary(),
answers: FilledQuestionary(filledQuestions: [:]),
completion: onboardingProvider.saveOnboardingAnswers
)
let homePresenter = HomePresenter()
_onboardingProvider = StateObject(wrappedValue: onboardingProvider)
_onboardingPresenter = StateObject(wrappedValue: onboardingPresenter)
_homePresenter = StateObject(wrappedValue: homePresenter)
}
var body: some Scene {
WindowGroup {
if onboardingProvider.needsOnboarding {
OnboardingView(presenter: onboardingPresenter)
} else {
TabView {
HomeView(presenter: homePresenter)
if let profile = onboardingProvider.filledOnboarding {
ProfileView(profile: profile)
}
}
}
}
} // body
}
Это приложение SwiftUI устанавливает свое начальное состояние с помощью оберток полей StateObject
. Оно инициализирует OnboardingProvider
, OnboardingPresenter
и HomePresenter
в своем методе init. Провайдер OnboardingProvider
отвечает за управление данными, связанными с онбордингом, а OnboardingPresenter
управляет логикой представления онбординга. HomePresenter
управляет главным домашним представлением.
В теле сцены приложения проверяется, нужна ли регистрация на сайте. Если да, то она представляет OnboardingView
с OnboardingPresenter
. В противном случае она представляет TabView
, содержащий HomeView
с HomePresenter
и, если доступно, ProfileView
.
Теперь настало время для домашнего представления. Логика проста:
- Получаем сообщение от пользователя.
- Используя сообщение, запрашиваем список вопросов из бэкенда.
- Показываем вопросы по одному, используя встроенную push-навигацию.
- Добавляем ответы к запросу и повторяем 2-4, пока бэкенд-доктор не вернет окончательный результат.
- Показываем финальный результат.
struct HomeView: View {
@StateObject var presenter: HomePresenter
var body: some View {
NavigationStack(path: $presenter.navigationPath) {
VStack {
// 1
Text("How are you?")
TextField("...", text: $presenter.message)
.lineLimit(5...10)
.formItem()
// 2
Button("Send", action: presenter.onSend)
}
.padding()
.navigationDestination(for: NavigationPage.self) { page in
switch page {
case .questinary(let questions, let answers):
// 3
QuestionaryView(
presenter: QuestionaryPresenter(
questions: questions,
answers: answers,
completion: presenter.onQuestionaryFilled
)
)
case .answer(let string):
// 5
VStack {
Text("The doctor says...")
Text(string)
.font(.title2)
.padding()
}
}
}
}
}
}
Похоже, я пропустил 4-й пункт... или нет? Поскольку представление не может содержать никакой логики, эту часть выполняет его презентер.
enum NavigationPage: Hashable {
case questinary(Questionary, FilledQuestionary)
case answer(String)
}
class HomePresenter: ObservableObject {
@Published var message: String = ""
@Published var navigationPath: [NavigationPage] = []
init(message: String = "") {
self.message = message
}
func onSend() {
Task {
let doctor = DoctorProvider()
let answer = try! await doctor.sendMessage(message: message)
switch answer {
case .questions(let questions):
navigationPath.append(.questinary(questions, FilledQuestionary(filledQuestions: [:])))
case .answer(let string):
navigationPath.append(.answer(string))
}
}
}
func onQuestionaryFilled(filled: FilledQuestionary) {
Task {
let doctor = DoctorProvider()
let answer = try! await doctor.sendAnswers(message: message, answers: filled)
switch answer {
case .questions(let newQuestions):
navigationPath.append(.questinary(newQuestions, filled))
case .answer(let string):
navigationPath.append(.answer(string))
}
}
}
}
Он управляет вводом сообщения пользователем и обновляет путь навигации на основе ответов от бэкенда.
При отправке сообщения метод onSend()
отправляет его на бэкенд с помощью DoctorProvider
и ожидает ответа. В зависимости от типа ответа он обновляет навигационный путь, отображая либо набор вопросов, либо окончательный ответ.
Аналогично, когда заполняется вопросник, метод onQuestionaryFilled()
отправляет заполненный вопросник на бэкенд и соответствующим образом обновляет путь навигации.
Здесь есть небольшое дублирование кода между методами onSend()
и onQuestionaryFilled()
, которое можно было бы отрефакторизовать в один метод для обработки обоих случаев. Однако оставим это как упражнение для дальнейшей доработки.
Модуль Questionary (View+Presenter) почти является копией Onboarding и просто делегирует логику до HomePresenter
, поэтому я не вижу необходимости показывать код. Опять же, для этого есть github.
Последнее, что я хочу показать, это две реализации DoctorProvider
, единственной обязанностью которых является вызов API и возврат DoctorResponse
. Первая использует наш бэкенд.
import Alamofire
enum DoctorResponse {
case questions(Questionary)
case answer(String)
init(from string: String) throws {
if let data = string.data(using: .utf8) {
if string.contains("\"questions\""){
let decoded = try! JSONDecoder().decode(Questionary.self, from: data)
self = .questions(decoded)
} else if string.contains("\"text\"") {
let decoded = try! JSONDecoder().decode(Answer.self, from: data)
self = .answer(decoded.text)
} else {
throw NSError(domain: "DoctorResponseError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Unknown response format"])
}
} else {
throw NSError(domain: "DoctorResponseError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid string encoding"])
}
}
}
class DoctorProvider {
private let baseUrl = ""
func sendMessage(message: String) async throws -> DoctorResponse {
try! await sendAnswers(message: message, answers: FilledQuestionary(filledQuestions: [:]))
}
func sendAnswers(message: String, answers: FilledQuestionary) async throws -> DoctorResponse {
struct DoctorParams: Codable {
var message: String
var userCard: [String: String]
var filledQuestionary: FilledQuestionary
}
let onboard = OnboardingProvider()
let paramsObject = DoctorParams(
message: message,
userCard: onboard.filledOnboarding!.filledQuestions,
filledQuestionary: answers
)
let encoder = JSONParameterEncoder.default
encoder.encoder.keyEncodingStrategy = .convertToSnakeCase
let responseString = try await AF.request(
baseUrl + "/doctor",
method: .post,
parameters: paramsObject,
encoder: encoder
).serializingString().value
return try! DoctorResponse(from: responseString)
}
}
Вторая вызывает openai api напрямую (подход backendless) и является практически копией модуля подсказок из бэкенда.
class PromptsProvider {
private(set) public var homeRole = "" // TODO: take from the backend
func message(message: String) -> String {
return message
}
func profile(profile: FilledQuestionary) -> String {
return try! jsonify(object: profile)
}
func answers(filled: FilledQuestionary) -> String {
return try! jsonify(object: filled)
}
// MARK: - Private
private func jsonify(object: Encodable) throws -> String {
let coder = JSONEncoder()
return String(data: try coder.encode(object), encoding: .utf8) ?? ""
}
}
class HouseMDAIProvider {
private var openAI: OpenAI
init() {
openAI = OpenAI(apiToken: "")
}
func sendMessage(message: String) async throws -> DoctorResponse {
try! await sendAnswers(message: message, answers: FilledQuestionary(filledQuestions: [:]))
}
func sendAnswers(message: String, answers: FilledQuestionary) async throws -> DoctorResponse {
// NOTE: Draft version, DI should be used instead!
let promptProvider = PromptsProvider()
let profile = OnboardingProvider().filledOnboarding!
let query = ChatQuery(model: .gpt3_5Turbo, messages: [
Chat(role: .system, content: promptProvider.homeRole),
Chat(role: .user, content: promptProvider.profile(profile: profile)),
Chat(role: .user, content: promptProvider.message(message: message)),
Chat(role: .user, content: promptProvider.answers(filled: answers)),
])
let result = try await openAI.chats(query: query)
return try! DoctorResponse(from: result.choices[0].message.content ?? "")
}
}
Другой пример
Посмотреть пример этой архитектуры в реальном приложении можно в моем проекте TwiTreads на github.com/MarkParker5/TwiTreads
Что делать дальше
- Интегрируйте аутентификацию и базу данных пользователей в бэкенд. Можете использовать официальный шаблон FastAPI из FastAPI Project Generation.
- Реализуйте логику аутентификации в приложении.
- Сосредоточьтесь на улучшении дизайна приложения, чтобы повысить удобство работы с ним. Давайте создавать красивые приложения!
Заключение
Приведенные проекты и ссылки на код служат реальными примерами, чтобы дать толчок вашей собственной разработке. Помните, что красота технологии заключается в итерациях. Начните с простого: создайте прототип и постоянно совершенствуйте его. Каждый шаг вперед приближает вас к овладению искусством разработки программного обеспечения и, возможно, к следующему большому прорыву в технологиях. Счастливого кодинга!
Автор: Марк Паркер
Телеграм-канал: t.me/parker_is_typing
Комментарии