feat: Add async HTTP requests with non-blocking loading

- Implement curl multi interface for async HTTP in HttpClient
- Add loading spinner animation during page load
- Support Esc key to cancel loading
- Non-blocking main loop with 50ms polling
- Loading state management (IDLE, LOADING_PAGE, LOADING_IMAGES)
- Preserve sync API for backward compatibility
This commit is contained in:
m1ngsama 2025-12-27 15:47:09 +08:00
parent 584660a518
commit 18859eef47
3 changed files with 376 additions and 7 deletions

View file

@ -15,6 +15,19 @@
using namespace tut;
// 浏览器加载状态
enum class LoadingState {
IDLE, // 空闲
LOADING_PAGE, // 正在加载页面
LOADING_IMAGES // 正在加载图片
};
// 加载动画帧
static const char* SPINNER_FRAMES[] = {
"", "", "", "", "", "", "", "", "", ""
};
static const int SPINNER_FRAME_COUNT = 10;
// 缓存条目
struct CacheEntry {
DocumentTree tree;
@ -70,6 +83,13 @@ public:
static constexpr int CACHE_MAX_AGE = 300; // 5分钟缓存
static constexpr size_t CACHE_MAX_SIZE = 20; // 最多缓存20个页面
// 异步加载状态
LoadingState loading_state = LoadingState::IDLE;
std::string pending_url; // 正在加载的URL
bool pending_force_refresh = false;
int spinner_frame = 0;
std::chrono::steady_clock::time_point last_spinner_update;
bool init_screen() {
if (!terminal.init()) {
return false;
@ -170,6 +190,162 @@ public:
return true;
}
// 启动异步页面加载
void start_async_load(const std::string& url, bool force_refresh = false) {
// 检查缓存
auto cache_it = page_cache.find(url);
bool use_cache = !force_refresh && cache_it != page_cache.end() &&
!cache_it->second.is_expired(CACHE_MAX_AGE);
if (use_cache) {
// 使用缓存,不需要网络请求
status_message = "⚡ Loading from cache...";
current_tree = html_parser.parse_tree(cache_it->second.html, url);
current_layout = layout_engine->layout(current_tree);
current_url = url;
scroll_pos = 0;
active_link = current_tree.links.empty() ? -1 : 0;
active_field = current_tree.form_fields.empty() ? -1 : 0;
search_ctx = SearchContext();
search_term.clear();
status_message = "" + (current_tree.title.empty() ? url : current_tree.title);
// 更新历史
if (!force_refresh) {
if (history_pos >= 0 && history_pos < static_cast<int>(history.size()) - 1) {
history.erase(history.begin() + history_pos + 1, history.end());
}
history.push_back(url);
history_pos = history.size() - 1;
}
// 加载图片(仍然同步,可以后续优化)
load_images(current_tree);
current_layout = layout_engine->layout(current_tree);
return;
}
// 需要网络请求,启动异步加载
pending_url = url;
pending_force_refresh = force_refresh;
loading_state = LoadingState::LOADING_PAGE;
spinner_frame = 0;
last_spinner_update = std::chrono::steady_clock::now();
status_message = std::string(SPINNER_FRAMES[0]) + " Connecting to " + extract_host(url) + "...";
http_client.start_async_fetch(url);
}
// 轮询异步加载状态返回true表示还在加载中
bool poll_loading() {
if (loading_state == LoadingState::IDLE) {
return false;
}
// 更新spinner动画
auto now = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - last_spinner_update).count();
if (elapsed >= 80) { // 每80ms更新一帧
spinner_frame = (spinner_frame + 1) % SPINNER_FRAME_COUNT;
last_spinner_update = now;
update_loading_status();
}
if (loading_state == LoadingState::LOADING_PAGE) {
auto state = http_client.poll_async();
switch (state) {
case AsyncState::COMPLETE:
handle_load_complete();
return false;
case AsyncState::FAILED: {
auto result = http_client.get_async_result();
status_message = "" + (result.error_message.empty() ?
"Connection failed" : result.error_message);
loading_state = LoadingState::IDLE;
return false;
}
case AsyncState::CANCELLED:
status_message = "⚠ Loading cancelled";
loading_state = LoadingState::IDLE;
return false;
case AsyncState::LOADING:
return true;
default:
return false;
}
}
return loading_state != LoadingState::IDLE;
}
// 更新加载状态消息
void update_loading_status() {
std::string spinner = SPINNER_FRAMES[spinner_frame];
if (loading_state == LoadingState::LOADING_PAGE) {
status_message = spinner + " Loading " + extract_host(pending_url) + "...";
} else if (loading_state == LoadingState::LOADING_IMAGES) {
status_message = spinner + " Loading images...";
}
}
// 处理页面加载完成
void handle_load_complete() {
auto response = http_client.get_async_result();
if (!response.is_success()) {
status_message = "❌ HTTP " + std::to_string(response.status_code);
loading_state = LoadingState::IDLE;
return;
}
// 解析HTML
current_tree = html_parser.parse_tree(response.body, pending_url);
// 添加到缓存
add_to_cache(pending_url, response.body);
// 布局计算
current_layout = layout_engine->layout(current_tree);
current_url = pending_url;
scroll_pos = 0;
active_link = current_tree.links.empty() ? -1 : 0;
active_field = current_tree.form_fields.empty() ? -1 : 0;
search_ctx = SearchContext();
search_term.clear();
// 更新历史(仅在非刷新时)
if (!pending_force_refresh) {
if (history_pos >= 0 && history_pos < static_cast<int>(history.size()) - 1) {
history.erase(history.begin() + history_pos + 1, history.end());
}
history.push_back(pending_url);
history_pos = history.size() - 1;
}
status_message = current_tree.title.empty() ? pending_url : current_tree.title;
// 加载图片(目前仍同步,可后续优化为异步)
load_images(current_tree);
current_layout = layout_engine->layout(current_tree);
loading_state = LoadingState::IDLE;
}
// 取消加载
void cancel_loading() {
if (loading_state != LoadingState::IDLE) {
http_client.cancel_async();
loading_state = LoadingState::IDLE;
status_message = "⚠ Cancelled";
}
}
void add_to_cache(const std::string& url, const std::string& html) {
// 限制缓存大小
if (page_cache.size() >= CACHE_MAX_SIZE) {
@ -357,33 +533,33 @@ public:
case Action::FOLLOW_LINK:
if (active_link >= 0 && active_link < static_cast<int>(current_tree.links.size())) {
load_page(current_tree.links[active_link].url);
start_async_load(current_tree.links[active_link].url);
}
break;
case Action::GO_BACK:
if (history_pos > 0) {
history_pos--;
load_page(history[history_pos]);
start_async_load(history[history_pos]);
}
break;
case Action::GO_FORWARD:
if (history_pos < static_cast<int>(history.size()) - 1) {
history_pos++;
load_page(history[history_pos]);
start_async_load(history[history_pos]);
}
break;
case Action::OPEN_URL:
if (!result.text.empty()) {
load_page(result.text);
start_async_load(result.text);
}
break;
case Action::REFRESH:
if (!current_url.empty()) {
load_page(current_url, true); // 强制刷新,跳过缓存
start_async_load(current_url, true); // 强制刷新,跳过缓存
}
break;
@ -724,15 +900,20 @@ void BrowserV2::run(const std::string& initial_url) {
}
if (!initial_url.empty()) {
load_url(initial_url);
pImpl->start_async_load(initial_url);
} else {
pImpl->show_help();
}
bool running = true;
while (running) {
// 轮询异步加载状态
pImpl->poll_loading();
// 渲染屏幕
pImpl->draw_screen();
// 获取输入非阻塞50ms超时
int ch = pImpl->terminal.get_key(50);
if (ch == -1) continue;
@ -742,6 +923,20 @@ void BrowserV2::run(const std::string& initial_url) {
continue;
}
// 如果正在加载Esc可以取消
if (pImpl->loading_state != LoadingState::IDLE && ch == 27) { // 27 = Esc
pImpl->cancel_loading();
continue;
}
// 加载时忽略大部分输入,只允许取消和退出
if (pImpl->loading_state != LoadingState::IDLE) {
if (ch == 'q' || ch == 'Q') {
running = false;
}
continue; // 忽略其他输入
}
auto result = pImpl->input_handler.handle_key(ch);
if (result.action == Action::QUIT) {
running = false;

View file

@ -25,8 +25,15 @@ public:
bool follow_redirects;
std::string cookie_file;
// 异步请求相关
CURLM* multi_handle = nullptr;
CURL* async_easy = nullptr;
AsyncState async_state = AsyncState::IDLE;
std::string async_response_body;
HttpResponse async_result;
Impl() : timeout(30),
user_agent("TUT-Browser/1.0 (Terminal User Interface Browser)"),
user_agent("TUT-Browser/2.0 (Terminal User Interface Browser)"),
follow_redirects(true) {
curl = curl_easy_init();
if (!curl) {
@ -36,13 +43,59 @@ public:
curl_easy_setopt(curl, CURLOPT_COOKIEFILE, "");
// Enable automatic decompression of supported encodings (gzip, deflate, etc.)
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
// 初始化multi handle用于异步请求
multi_handle = curl_multi_init();
if (!multi_handle) {
throw std::runtime_error("Failed to initialize CURL multi handle");
}
}
~Impl() {
// 清理异步请求
cleanup_async();
if (multi_handle) {
curl_multi_cleanup(multi_handle);
}
if (curl) {
curl_easy_cleanup(curl);
}
}
void cleanup_async() {
if (async_easy) {
curl_multi_remove_handle(multi_handle, async_easy);
curl_easy_cleanup(async_easy);
async_easy = nullptr;
}
async_state = AsyncState::IDLE;
async_response_body.clear();
}
void setup_easy_handle(CURL* handle, const std::string& url) {
curl_easy_setopt(handle, CURLOPT_URL, url.c_str());
curl_easy_setopt(handle, CURLOPT_TIMEOUT, timeout);
curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT, 10L);
curl_easy_setopt(handle, CURLOPT_USERAGENT, user_agent.c_str());
if (follow_redirects) {
curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(handle, CURLOPT_MAXREDIRS, 10L);
}
curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(handle, CURLOPT_SSL_VERIFYHOST, 2L);
if (!cookie_file.empty()) {
curl_easy_setopt(handle, CURLOPT_COOKIEFILE, cookie_file.c_str());
curl_easy_setopt(handle, CURLOPT_COOKIEJAR, cookie_file.c_str());
} else {
curl_easy_setopt(handle, CURLOPT_COOKIEFILE, "");
}
curl_easy_setopt(handle, CURLOPT_ACCEPT_ENCODING, "");
}
};
HttpClient::HttpClient() : pImpl(std::make_unique<Impl>()) {}
@ -297,4 +350,106 @@ void HttpClient::set_follow_redirects(bool follow) {
void HttpClient::enable_cookies(const std::string& cookie_file) {
pImpl->cookie_file = cookie_file;
}
// ==================== 异步请求实现 ====================
void HttpClient::start_async_fetch(const std::string& url) {
// 如果有正在进行的请求,先取消
if (pImpl->async_easy) {
cancel_async();
}
// 创建新的easy handle
pImpl->async_easy = curl_easy_init();
if (!pImpl->async_easy) {
pImpl->async_state = AsyncState::FAILED;
pImpl->async_result.error_message = "Failed to create CURL handle";
return;
}
// 配置请求
pImpl->setup_easy_handle(pImpl->async_easy, url);
// 设置写回调
pImpl->async_response_body.clear();
curl_easy_setopt(pImpl->async_easy, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(pImpl->async_easy, CURLOPT_WRITEDATA, &pImpl->async_response_body);
// 添加到multi handle
curl_multi_add_handle(pImpl->multi_handle, pImpl->async_easy);
pImpl->async_state = AsyncState::LOADING;
pImpl->async_result = HttpResponse{}; // 重置结果
}
AsyncState HttpClient::poll_async() {
if (pImpl->async_state != AsyncState::LOADING) {
return pImpl->async_state;
}
// 执行非阻塞的multi perform
int still_running = 0;
CURLMcode mc = curl_multi_perform(pImpl->multi_handle, &still_running);
if (mc != CURLM_OK) {
pImpl->async_result.error_message = curl_multi_strerror(mc);
pImpl->async_state = AsyncState::FAILED;
pImpl->cleanup_async();
return pImpl->async_state;
}
// 检查是否有完成的请求
int msgs_left = 0;
CURLMsg* msg;
while ((msg = curl_multi_info_read(pImpl->multi_handle, &msgs_left))) {
if (msg->msg == CURLMSG_DONE) {
CURL* easy = msg->easy_handle;
CURLcode result = msg->data.result;
if (result == CURLE_OK) {
// 获取响应信息
long http_code = 0;
curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &http_code);
pImpl->async_result.status_code = static_cast<int>(http_code);
char* content_type = nullptr;
curl_easy_getinfo(easy, CURLINFO_CONTENT_TYPE, &content_type);
if (content_type) {
pImpl->async_result.content_type = content_type;
}
pImpl->async_result.body = std::move(pImpl->async_response_body);
pImpl->async_state = AsyncState::COMPLETE;
} else {
pImpl->async_result.error_message = curl_easy_strerror(result);
pImpl->async_state = AsyncState::FAILED;
}
// 清理handle但保留状态供获取结果
curl_multi_remove_handle(pImpl->multi_handle, pImpl->async_easy);
curl_easy_cleanup(pImpl->async_easy);
pImpl->async_easy = nullptr;
}
}
return pImpl->async_state;
}
HttpResponse HttpClient::get_async_result() {
HttpResponse result = std::move(pImpl->async_result);
pImpl->async_result = HttpResponse{};
pImpl->async_state = AsyncState::IDLE;
return result;
}
void HttpClient::cancel_async() {
if (pImpl->async_easy) {
pImpl->cleanup_async();
pImpl->async_state = AsyncState::CANCELLED;
}
}
bool HttpClient::is_async_active() const {
return pImpl->async_state == AsyncState::LOADING;
}

View file

@ -5,6 +5,15 @@
#include <cstdint>
#include <memory>
// 异步请求状态
enum class AsyncState {
IDLE, // 无活跃请求
LOADING, // 请求进行中
COMPLETE, // 请求成功完成
FAILED, // 请求失败
CANCELLED // 请求被取消
};
struct HttpResponse {
int status_code;
std::string body;
@ -36,10 +45,20 @@ public:
HttpClient();
~HttpClient();
// 同步请求接口
HttpResponse fetch(const std::string& url);
BinaryResponse fetch_binary(const std::string& url);
HttpResponse post(const std::string& url, const std::string& data,
const std::string& content_type = "application/x-www-form-urlencoded");
// 异步请求接口
void start_async_fetch(const std::string& url);
AsyncState poll_async(); // 非阻塞轮询,返回当前状态
HttpResponse get_async_result(); // 获取结果并重置状态
void cancel_async(); // 取消当前异步请求
bool is_async_active() const; // 是否有活跃的异步请求
// 配置
void set_timeout(long timeout_seconds);
void set_user_agent(const std::string& user_agent);
void set_follow_redirects(bool follow);