iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Software Development

30天快速上手製作WPF選股工具 — 從C#基礎到LiteDB與Web API整合系列 第 25

Day 25 — 在 WPF 使用 Microsoft.Extensions.DependencyInjection 做 DI

  • 分享至 

  • xImage
  •  

你會得到什麼

  • MyStockApp 中導入 MS.DI 的最小可行實作
  • Generic Host 建立 IServiceProvider
  • 一次註冊 IDatabaseRepository / IStockApiService / IQuoteFetchService / IIndicatorService / IBuildIndicatorsJob
  • MainWindow 與 StockFilterViewModel 都走「建構式注入」
  • 支援 HttpClientFactory,HTTP 呼叫更穩定

1) 名稱統一:IStockRepository → IDatabaseRepository

為了強調「資料層門面」的角色,我們把介面統一為 IDatabaseRepository
實作細節(LiteDB)今天不展開;你只要知道 ViewModel 只依賴 IDatabaseRepository,容器會提供實作。


2) 安裝套件

在 WPF 專案 MyStockApp 安裝以下 NuGet:

dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Http

3) App.xaml:改用程式碼啟動(取消 StartupUri)

<!-- MyStockApp/App.xaml -->
<Application x:Class="MyStockApp.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <!-- 不指定 StartupUri,改由 App.xaml.cs 控制 -->
</Application>

4) App.xaml.cs:建立 Generic Host 與 DI 容器

// MyStockApp/App.xaml.cs
using System;
using System.Windows;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using System.Net.Http;

namespace MyStockApp
{
    public partial class App : Application
    {
        private IHost _host;

        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            _host = Host.CreateDefaultBuilder()
                .ConfigureServices((context, services) =>
                {
                    // 1) DB 門面
                    services.AddSingleton<IDatabaseRepository, LiteDbStockRepository>(sp =>
                        new LiteDbStockRepository("StockData.db")); // 路徑可抽設定

                    // 2) HttpClientFactory + API 服務(Typed Client)
                    services.AddHttpClient<IStockApiService, TwseStockApiService>(client =>
                    {
                        // client.BaseAddress = new Uri("https://openapi.twse.com.tw/");
                        // 可設定逾時、Header 等
                        client.Timeout = TimeSpan.FromSeconds(30);
                    });

                    // 3) 資料抓取 / 指標計算 / 批次工作
                    services.AddSingleton<IQuoteFetchService, QuoteFetchService>();
                    services.AddSingleton<IIndicatorService, IndicatorService>();
                    services.AddSingleton<IBuildIndicatorsJob, BuildIndicatorsJob>();

                    // 4) ViewModel 與 View
                    services.AddTransient<StockFilterViewModel>();  // 每次新建一個 VM
                    services.AddTransient<MainWindow>();            // 由容器建立並注入 VM
                })
                .Build();

            // 從容器取得 MainWindow(其依賴會自動注入)
            var main = _host.Services.GetRequiredService<MainWindow>();
            main.Show();
        }

        protected override async void OnExit(ExitEventArgs e)
        {
            if (_host is not null) await _host.StopAsync();
            _host?.Dispose();
            base.OnExit(e);
        }
    }
}

5) 服務與介面關係(快速回顧)

  • IDatabaseRepository:資料存取門面(股票主檔、日 K、缺漏日、指標…)
  • IStockApiService:股票 API(已把 GetDailyQuotes(code, start, end) 從 ICrawler 移過來)
  • IQuoteFetchService:從 DB 列出股票 → 判斷需補抓區間 → 呼叫 API → Upsert → 記錄缺漏日
  • IIndicatorService:以 DailyQuote 計算 KD/MACD
  • IBuildIndicatorsJob:流程編排(抓日線 → 算指標 → 存回)

以上在 Day 24 已完成;今天只是把它們註冊進 DI。


6) MainWindow:用建構式注入 ViewModel

// MyStockApp/MainWindow.xaml.cs
using System.Windows;
using Microsoft.Extensions.DependencyInjection;

namespace MyStockApp
{
    public partial class MainWindow : Window
    {
        public MainWindow(StockFilterViewModel vm)
        {
            InitializeComponent();
            DataContext = vm; // 由 DI 傳入,解除 new 依賴
        }
    }
}
<!-- MyStockApp/MainWindow.xaml -->
<Window x:Class="MyStockApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MyStockApp" Width="900" Height="600">
    <DockPanel>
        <!-- 你可以把 Day 23 的樣式 / Day 24 的表格延續使用 -->
        <ContentControl Content="{Binding}">
            <!-- 綁定的是 StockFilterViewModel,DataContext 已在 code-behind 設好 -->
        </ContentControl>
    </DockPanel>
</Window>

7) 完整版 StockFilterViewModel(已用 IDatabaseRepository)

重點:

  • 依賴 IDatabaseRepository(容器注入)
  • 提供「載入股票」「重整指標」「清除條件」命令
  • 篩選:關鍵字 / 指定日期的 KD 黃金交叉 / MACD 柱正
  • 以快取 _indicatorCache 減少資料庫打點
// MyStockApp/ViewModel/StockFilterViewModel.cs
using System;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Data;
using System.Windows.Input;

namespace MyStockApp
{
    public class StockFilterViewModel : INotifyPropertyChanged
    {
        private readonly IDatabaseRepository _repo;

        public StockFilterViewModel(IDatabaseRepository repo)
        {
            _repo = repo ?? throw new ArgumentNullException(nameof(repo));

            View = CollectionViewSource.GetDefaultView(Stocks);
            View.Filter = FilterPredicate;

            _selectedDate = DateTime.Now.Date;

            LoadFromLocalCommand = new AsyncCommand(LoadFromLocalAsync);
            RefreshIndicatorsCommand = new AsyncCommand(RefreshIndicatorCacheAsync, () => Stocks.Count > 0);
            ClearFiltersCommand = new RelayCommand(_ =>
            {
                Query = string.Empty;
                OnlyKdGoldenCross = false;
                OnlyMacdPositive = false;
            });
        }

        // ===== 資料源 =====
        public ObservableCollection<StockProfile> Stocks { get; } = new();
        public ICollectionView View { get; }

        // ===== 條件 =====
        private string _query = string.Empty;
        public string Query
        {
            get => _query;
            set { _query = value ?? string.Empty; OnPropertyChanged(nameof(Query)); View.Refresh(); }
        }

        private DateTime _selectedDate;
        public DateTime SelectedDate
        {
            get => _selectedDate;
            set { _selectedDate = value.Date; OnPropertyChanged(nameof(SelectedDate)); _ = RefreshIndicatorCacheAsync(); }
        }

        private bool _onlyKdGoldenCross;
        public bool OnlyKdGoldenCross
        {
            get => _onlyKdGoldenCross;
            set { _onlyKdGoldenCross = value; OnPropertyChanged(nameof(OnlyKdGoldenCross)); View.Refresh(); }
        }

        private bool _onlyMacdPositive;
        public bool OnlyMacdPositive
        {
            get => _onlyMacdPositive;
            set { _onlyMacdPositive = value; OnPropertyChanged(nameof(OnlyMacdPositive)); View.Refresh(); }
        }

        // ===== 指令 =====
        public ICommand LoadFromLocalCommand { get; }
        public ICommand RefreshIndicatorsCommand { get; }
        public ICommand ClearFiltersCommand { get; }

        // ===== 指標快取(指定日)=====
        private readonly Dictionary<string, DailyIndicator?> _indicatorCache = new();

        private async Task LoadFromLocalAsync()
        {
            Stocks.Clear();
            var profiles = await _repo.QueryProfilesAsync(); // 由資料層提供主檔清單
            foreach (var p in profiles) Stocks.Add(p);
            await RefreshIndicatorCacheAsync();
        }

        private async Task RefreshIndicatorCacheAsync()
        {
            _indicatorCache.Clear();
            foreach (var s in Stocks)
            {
                var ind = await _repo.GetIndicatorAtOrBeforeAsync(s.Code, SelectedDate);
                _indicatorCache[s.Code] = ind;
            }
            (RefreshIndicatorsCommand as AsyncCommand)?.RaiseCanExecuteChanged();
            View.Refresh();
        }

        private bool FilterPredicate(object obj)
        {
            if (obj is not StockProfile s) return false;

            if (!string.IsNullOrWhiteSpace(Query))
            {
                var q = Query.Trim();
                if (!s.Code.Contains(q, StringComparison.OrdinalIgnoreCase) &&
                    !s.Name.Contains(q, StringComparison.OrdinalIgnoreCase))
                    return false;
            }

            if (OnlyKdGoldenCross || OnlyMacdPositive)
            {
                if (!_indicatorCache.TryGetValue(s.Code, out var ind) || ind is null) return false;

                if (OnlyKdGoldenCross && !(ind.K.HasValue && ind.D.HasValue && ind.K.Value > ind.D.Value))
                    return false;

                if (OnlyMacdPositive && !(ind.MACDHist.HasValue && ind.MACDHist.Value > 0m))
                    return false;
            }

            return true;
        }

        public event PropertyChangedEventHandler? PropertyChanged;
        protected void OnPropertyChanged(string name)
            => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

上例使用的 StockProfileDailyIndicatorAsyncCommandRelayCommand 請沿用專案內既有定義。
如果 IDatabaseRepository 方法名稱不同(例如 GetIndicatorAtOrBeforeAsync),請對照你的介面做小調整即可。


8) 小技巧:為什麼這樣分配 Lifetime?

  • IDatabaseRepositoryIndicatorServiceQuoteFetchServiceBuildIndicatorsJobSingleton

    • 因為它們是「應用層級」服務,狀態少、可共用。
  • StockFilterViewModelMainWindowTransient

    • UI 元素生命週期由視窗控制,通常每開一個就建一個。
  • IStockApiServiceAddHttpClient(背後用 HttpClientFactory

    • 自動處理連線共用、DNS 變更、Handler 壽命等問題。


小結

  • 你已把 MyStockApp 升級到「用 DI 管理依賴」的良好結構。
  • MS.DI 在 WPF 的接入重點是:用 Generic Host 建容器,註冊所有服務與 ViewModel,然後由容器建立 MainWindow
  • 這會讓日後擴充(例如更多策略服務、報表、背景同步)簡單許多。


上一篇
Day 24 - 抓取每日 K 線 → 計算 KD/MACD → 存回 DB
下一篇
Day 26 — 安全儲存 API Token:以 FinMind 為例
系列文
30天快速上手製作WPF選股工具 — 從C#基礎到LiteDB與Web API整合27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言