127 lines
5.8 KiB
Markdown
127 lines
5.8 KiB
Markdown
# 開發過程摘要
|
||
|
||
記錄這個專案的演進脈絡、遇到的問題、以及為什麼做出這些選擇。
|
||
|
||
## 需求演進
|
||
|
||
| 階段 | 需求 | 產出 |
|
||
|------|------|------|
|
||
| 1 | 抓台銀 USD / JPY / EUR 即期賣出 | Python CLI (`bot_rates.py`) |
|
||
| 2 | 要給別人用、Windows、GUI、雙擊即跑、不要裝東西 | Python tkinter + PyInstaller 方案 |
|
||
| 3 | 太麻煩,改用 Rust,使用者無 Windows 機器 | Rust + `eframe` + 交叉編譯 |
|
||
| 4 | 也給我一份 macOS 能跑的 | `BotRates.app` bundle |
|
||
| 5 | 壓縮 exe 方便傳送 | `bot_rates.zip`(5.4 MB → 2.7 MB) |
|
||
| 6 | 加上金額輸入 / 即時換算 | 三欄布局:幣別 / 輸入 / TWD |
|
||
| 7 | 換算結果要能直接複製 | 「複製」按鈕 + 可選取文字 |
|
||
|
||
## 階段 1:Python CLI
|
||
|
||
直接用 `urllib` 抓台銀 CSV,起初用 `csv.DictReader`,但踩到一個坑——CSV 有**重複欄名**(買入、賣出區段都用 `匯率 / 現金 / 即期`),`DictReader` 只會保留最後一個同名欄位,所以找不到「本行即期賣出」。
|
||
|
||
**解法**:改用位置索引,`即期賣出` 在 col 13。
|
||
|
||
## 階段 2:Python GUI(最終未採用)
|
||
|
||
計畫:`tkinter` + ttk 美化 + `PyInstaller --onefile --windowed` 打包成 exe。寫了 `bot_rates_gui.py` 和 `build.bat`。
|
||
|
||
**問題**:使用者手上沒有 Windows 機器,PyInstaller 必須在目標 OS 上執行,無法從 macOS 直接產出 Windows exe。只能給使用者 `build.bat` 要他在 Windows 上自己打包——違背「不要裝東西」的初衷。
|
||
|
||
## 階段 3:改用 Rust
|
||
|
||
Rust 可以從 macOS 乾淨地交叉編譯到 Windows,單一靜態執行檔,零 runtime 依賴,剛好解決 Python 方案的痛點。
|
||
|
||
### 技術選型
|
||
|
||
| 選擇 | 理由 |
|
||
|------|------|
|
||
| `eframe` / `egui` | Immediate mode GUI,使用 OpenGL(Windows 內建),不需要 GTK / Qt 這類系統依賴。交叉編譯幾乎沒坑。 |
|
||
| `ureq` + `rustls` | `reqwest` 預設用 `native-tls` 會綁 OpenSSL 或 Windows SChannel,交叉編譯會有系統 library 問題。`ureq` 搭 `rustls` 是純 Rust,無系統依賴。 |
|
||
| `x86_64-pc-windows-gnu` | 用 mingw-w64 當 linker,比 `-msvc` 少了需要 Windows SDK 的麻煩。`brew install mingw-w64` 就好。 |
|
||
|
||
### 踩過的坑
|
||
|
||
**LLVM 在 release 編譯時 SIGSEGV**
|
||
|
||
```
|
||
error: rustc interrupted by SIGSEGV, printing backtrace
|
||
llvm::DeadArgumentEliminationPass::run
|
||
```
|
||
|
||
第一次 release 編譯時,`Cargo.toml` 用了 `opt-level = "z"` + `lto = true`,LLVM 的 DeadArgumentElimination pass 在處理 `eframe` 依賴時堆疊用光。
|
||
|
||
**解法**:
|
||
1. 把 `opt-level` 從 `"z"` 改成 `3`、`lto` 從 `true` 改成 `"thin"`(降低 pass 壓力)
|
||
2. 加上 `RUST_MIN_STACK=16777216`(把執行緒堆疊從預設 2MB 拉到 16MB)
|
||
|
||
兩個同時做下去就過了。
|
||
|
||
**CJK 字型**
|
||
|
||
egui 預設字型不含中文字,幣別名稱會顯示成「豆腐」。
|
||
|
||
**解法**:不 bundle 字型(會讓 exe 大 5-15MB),改在執行期讀取作業系統字型。
|
||
- Windows:`C:\Windows\Fonts\msjh.ttc`(微軟正黑體)
|
||
- macOS:`/System/Library/Fonts/PingFang.ttc`
|
||
|
||
fallback 鏈有多個候選(msjhl / msyh / simhei),找到第一個能讀的就用。
|
||
|
||
## 階段 4:macOS `.app` bundle
|
||
|
||
`cargo build --release` 只產出裸執行檔,從 Finder 雙擊會跳 terminal 然後才開 GUI,體驗差。
|
||
|
||
**解法**:手動組 `.app` bundle——其實就是特定目錄結構加一個 `Info.plist`:
|
||
|
||
```
|
||
BotRates.app/
|
||
├── Contents/
|
||
│ ├── Info.plist # 告訴 Finder 這是 app
|
||
│ └── MacOS/BotRates # 實際執行檔
|
||
```
|
||
|
||
寫 plist 時標示 `CFBundlePackageType = APPL` 和 `CFBundleExecutable = BotRates` 即可。另外跑 `xattr -cr` 清掉 quarantine 屬性避免 Gatekeeper 擋下。
|
||
|
||
## 階段 5:壓縮
|
||
|
||
`bot_rates.exe` 5.4 MB,`zip -9` 後 2.7 MB(壓縮率 50%)。PE 檔因為有大量重複的 LLVM / rustc 樣板程式碼壓縮效果很好。
|
||
|
||
## 階段 6:金額換算
|
||
|
||
重新設計每一列為三欄 horizontal layout:
|
||
|
||
```
|
||
[幣別+匯率 寬110] [輸入 寬120] [ → ] [TWD 靠右]
|
||
```
|
||
|
||
狀態放在 `App.amounts: HashMap<String, String>`(幣別 → 輸入字串)。為什麼存字串而不是 `f64`?因為使用者輸入過程中可能有中間態(例如 `"100."`、空字串、`"1,000"`),存字串才能保留原樣。
|
||
|
||
輸入解析用 `parse_amount`,忽略逗號和空白再 parse。
|
||
|
||
TWD 顯示用 `format_twd`,手刻千分位 separator(沒引入 `num-format` crate 以減少 binary size)。
|
||
|
||
## 階段 7:複製功能
|
||
|
||
兩種複製方式並存:
|
||
|
||
1. **按鈕複製**(主要 UX):按「複製」→ `ui.output_mut(|o| o.copied_text = raw)`,複製純數字 `3162.00`(不含 `NT$`、不含逗號)。原因:這樣貼到 Excel 會被識別成數字而不是文字。
|
||
2. **選取複製**:`egui::Label::new(...).selectable(true)`,使用者可以用滑鼠拉選 TWD 文字後 `Cmd/Ctrl+C`。
|
||
|
||
按下按鈕後短暫顯示「已複製」(1.2 秒),用 `Option<(String, Instant)>` 追蹤狀態 + `ctx.request_repaint_after` 觸發回復重繪。
|
||
|
||
## 最終成果
|
||
|
||
| 項目 | 數值 |
|
||
|------|------|
|
||
| Windows exe 大小 | 5.4 MB(zip 後 2.7 MB) |
|
||
| macOS 執行檔大小 | 5.2 MB |
|
||
| 執行期依賴 | 無(兩平台皆零依賴) |
|
||
| 首次冷啟動 | < 1 秒 |
|
||
| 抓匯率耗時 | 網路 + 解析 < 500ms |
|
||
| 總原始碼行數 | ~310 行 Rust |
|
||
|
||
## 如果要擴充
|
||
|
||
- **加更多幣別**:改 `TARGETS` 即可,其他地方都自動處理。
|
||
- **換成即期買入 / 現金匯率**:改 `fetch_rates` 裡的 col index(3 買入 / 13 賣出 / 2 現金買入 / 12 現金賣出)。
|
||
- **雙向換算**(TWD → 外幣):在每列加第二個輸入框,狀態多存一組字串;注意避免無限循環更新。
|
||
- **歷史紀錄 / 圖表**:台銀有 `flcsv/1/month` 等端點可以抓歷史,但就不是「即期」而是均價了。
|