Skip to main content

项目架构:从模型到视图

GPUI 不强制 MVC、MVVM 或 Redux,但它天然鼓励一种清晰的 Entity-View-Model 组织方式:业务数据保持为普通 Rust 类型,视图持有 UI 状态和组件句柄,实体负责让状态拥有生命周期、订阅关系和重绘通知。

HiPoster 的目录边界

HiPoster 的核心目录可以整理为:

src/
├── main.rs # 平台入口,只调用库层 run()
├── lib.rs # 应用启动、菜单、Action、窗口创建
├── model.rs # HTTP 请求和响应模型
├── http.rs # reqwest 异步网络层
├── app.rs # 根视图:Tabs、历史记录、主题、全局布局
├── api_tab.rs # 单个请求 Tab:表单、发送、响应展示
├── theme.rs # 主题枚举、颜色表和主题转换
├── assets.rs # 应用资源、图标与静态文件
└── about.rs # 关于窗口

这种拆法的优点是:

  • model.rs 不依赖 GPUI,方便序列化、测试和迁移。
  • http.rs 不依赖具体 UI,方便替换请求库或做集成测试。
  • app.rs 只管理全局状态,单个请求编辑器的细节下沉到 api_tab.rs
  • 主题、资源、关于窗口都是独立模块,不污染请求业务。

Model:保持纯粹

HTTP 调试器的业务模型可以是完全普通的 Rust 类型:

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum HttpMethod {
GET,
POST,
PUT,
DELETE,
PATCH,
HEAD,
OPTIONS,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpRequest {
pub method: HttpMethod,
pub url: String,
pub headers: Vec<Header>,
pub params: Vec<Header>,
pub body: HttpBody,
pub content_type: String,
pub auth: AuthData,
}

模型层的原则:

  • Clone,因为 UI 事件和异步任务经常需要移动数据。
  • Serialize / Deserialize,方便历史记录和配置落盘。
  • 不直接引用 Entity<T>WindowContext<T> 或组件状态。
  • 用枚举表达状态空间,例如 HttpBodyAuthType,避免魔法字符串扩散。

Network:隔离 IO

网络层只接收 HttpRequest,返回 HttpResponse

pub async fn execute_request(request: &HttpRequest) -> anyhow::Result<HttpResponse> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;

let response = client
.request(reqwest::Method::GET, &request.url)
.send()
.await?;

Ok(HttpResponse {
status_code: response.status().as_u16(),
status_text: response.status().canonical_reason().unwrap_or("").to_string(),
headers: Vec::new(),
body: response.text().await?,
size: 0,
elapsed_ms: 0,
})
}

真实项目里还要处理方法映射、自定义 Header、认证、Body、Multipart、错误消息和耗时统计。重点是:网络层不应该知道按钮、输入框、Tab 或主题。

View:状态容器与渲染函数

GPUI 里的 View 通常是一个结构体加一个 Render 实现:

pub struct ApiTab {
pub url_input: Entity<InputState>,
pub body_input: Entity<InputState>,
pub request: HttpRequest,
pub response: Option<HttpResponse>,
pub loading: bool,
}

impl Render for ApiTab {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.v_flex()
.size_full()
.child(self.render_url_bar(window, cx))
.child(self.render_response(cx))
}
}

视图层可以持有两类状态:

  • 业务快照:requestresponseloading、当前选中的 Tab。
  • 组件句柄:Entity<InputState>、子视图 Entity<ApiTab>、订阅 Subscription

不要把所有字段都丢到一个大结构体里。根视图只管理应用级状态;业务面板管理自己的局部状态;纯函数负责从输入状态收集请求模型。

Entity:让状态进入 GPUI 运行时

普通结构体只有 Rust 生命周期;Entity<T> 才有 GPUI 生命周期。创建一个视图时通常使用:

let tab = cx.new(|cx| ApiTab::new(window, cx));

Entity<T> 是句柄,不是数据本体。读取和修改要通过上下文:

let url = tab.read(cx).request.url.clone();

tab.update(cx, |tab, cx| {
tab.loading = true;
cx.notify();
});

这让 GPUI 可以跟踪谁变了、谁需要通知、哪些窗口需要重绘。

Root View:应用的边界

HiPoster 的根视图 Hiposter 管理:

  • tabs: Vec<Entity<ApiTab>>
  • active_tab_index
  • history
  • theme
  • 子视图订阅列表

这是一种非常实用的边界:根视图负责“应用级导航与全局资源”,Tab 负责“单个请求编辑和响应展示”。当项目继续增长时,还可以再拆出 SidebarRequestPanelResponsePanelThemePicker 等视图。

推荐架构模板

新建 GPUI 项目时,可以先按这个模板开局:

src/
├── main.rs
├── lib.rs
├── model.rs
├── services/
│ ├── mod.rs
│ └── http.rs
├── views/
│ ├── mod.rs
│ ├── app.rs
│ └── request_tab.rs
├── theme.rs
├── assets.rs
└── persistence.rs

当逻辑变多时,优先拆服务和纯函数;当 UI 变复杂时,优先拆视图和渲染辅助函数。GPUI 的 Render 会反复运行,所以不要把昂贵计算藏在渲染树中,后面的性能章节会专门展开。