
تكون واجهات المستخدم للتطبيقات الحديثة معقدة - غالبًا ما يكون من الضروري تنفيذ دعم التنقل في الصفحة ومعالجة حقول الإدخال بأنواعها المختلفة وعرض المعلومات أو إخفائها استنادًا إلى المعلمات التي حددها المستخدم. في الوقت نفسه ، من أجل تحسين UX ، يجب على التطبيق حفظ حالة عناصر الواجهة على القرص أثناء التعليق أو إيقاف التشغيل ، واستعادة الحالة من القرص عند إعادة تشغيل البرنامج.
يقترح إطار ReactiveUI MVVM الحفاظ على حالة التطبيق من خلال إجراء تسلسل للرسم البياني لنماذج العروض التقديمية في الوقت الذي يتم فيه تعليق البرنامج ، في حين تختلف آليات تحديد وقت التعليق عن الأطر والأنظمة الأساسية. لذلك ، بالنسبة لـ WPF ، يتم استخدام حدث Exit
، من أجل Xamarin.Android - ActivityPaused
، من أجل Xamarin.iOS - DidEnterBackground
، لـ OnLaunched
- OnLaunched
overload.
في هذه المقالة ، سننظر في استخدام ReactiveUI لحفظ واستعادة حالة البرنامج باستخدام واجهة المستخدم الرسومية ، بما في ذلك حالة جهاز التوجيه ، باستخدام إطار عمل واجهة المستخدم الرسومية Avalonia عبر النظام الأساسي كمثال . تفترض المادة فهمًا أساسيًا لنمط تصميم MVVM والبرمجة التفاعلية في سياق لغة C # ومنصة .NET للقارئ. تنطبق الخطوات الواردة في هذه المقالة على نظامي التشغيل Windows 10 و Ubuntu 18.
إنشاء المشروع
لمحاولة التوجيه في العمل ، قم بإنشاء مشروع .NET Core جديد من قالب Avalonia ، قم بتثبيت حزمة Avalonia.ReactiveUI
- طبقة رقيقة من تكامل Avalonia و ReactiveUI. تأكد من تثبيت .NET Core SDK و git قبل البدء.
git clone https://github.com/AvaloniaUI/avalonia-dotnet-templates git --git-dir ./avalonia-dotnet-templates/.git checkout 9263c6b dotnet new --install ./avalonia-dotnet-templates dotnet new avalonia.app -o ReactiveUI.Samples.Suspension cd ./ReactiveUI.Samples.Suspension dotnet add package Avalonia.ReactiveUI dotnet add package Avalonia.Desktop dotnet add package Avalonia
تأكد من أن التطبيق يبدأ ويعرض نافذة تقول مرحبا بكم في أفالونيا!
# Use .NET Core version which you have installed. # It can be netcoreapp2.0, netcoreapp2.1 and so on. dotnet run --framework netcoreapp3.0

قم بتوصيل Avalonia pre-builds من MyGet
للاتصال واستخدام أحدث إصدارات Avalonia التي يتم نشرها تلقائيًا إلى MyGet عندما يتغير الفرع master
لمستودع Avalonia في GitHub ، نستخدم ملف التكوين المصدر لحزمة nuget.config
. لكي ترى IDE و .NET Core CLI nuget.config
، تحتاج إلى إنشاء ملف sln
للمشروع الذي تم إنشاؤه أعلاه. نستخدم أدوات .NET Core CLI:
dotnet new sln # Ctrl+C dotnet sln ReactiveUI.Samples.Suspension.sln add ReactiveUI.Samples.Suspension.csproj
قم nuget.config
ملف nuget.config
في مجلد بملف .sln
للمحتوى التالي:
<?xml version="1.0" encoding="utf-8"?> <configuration> <packageSources> <add key="AvaloniaCI" value="https://www.myget.org/F/avalonia-ci/api/v2" /> </packageSources> </configuration>
قد تحتاج إلى إعادة تشغيل IDE ، أو إلغاء تحميل وتنزيل الحل بأكمله. سنقوم بتحديث حزم Avalonia إلى الإصدار المطلوب (على الأقل 0.9.1
) باستخدام واجهة مدير حزمة NuGet الخاصة بك IDE ، أو باستخدام أدوات سطر أوامر Windows أو محطة Linux:
dotnet add package Avalonia.ReactiveUI --version 0.9.1 dotnet add package Avalonia.Desktop --version 0.9.1 dotnet add package Avalonia --version 0.9.1 cat ReactiveUI.Samples.Suspension.csproj
الآن يبدو ملف المشروع ReactiveUI.Samples.Suspension.csproj
كما يلي:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>netcoreapp3.0</TargetFramework> </PropertyGroup> <ItemGroup> <Compile Update="**\*.xaml.cs"> <DependentUpon>%(Filename)</DependentUpon> </Compile> <AvaloniaResource Include="**\*.xaml"> <SubType>Designer</SubType> </AvaloniaResource> </ItemGroup> <ItemGroup> <PackageReference Include="Avalonia" Version="0.9.1" /> <PackageReference Include="Avalonia.Desktop" Version="0.9.1" /> <PackageReference Include="Avalonia.ReactiveUI" Version="0.9.1" /> </ItemGroup> </Project>
قم ViewModels/
Views/
و ViewModels/
المجلدات في جذر المشروع ، وقم بتغيير اسم فئة MainView
إلى MainView
للراحة ، MainView
إلى الدليل Views/
، وتغيير مساحات الأسماء وفقًا لـ ReactiveUI.Samples.Suspension.Views
. App.xaml.cs
محتويات App.xaml.cs
Program.cs
و App.xaml.cs
- تطبيق استدعاء UseReactiveUI
على منشئ تطبيق Avalonia ، ونقل التهيئة للعرض الرئيسي إلى OnFrameworkInitializationCompleted
ليتوافق مع توصيات إدارة دورة حياة التطبيق:
Program.cs
class Program {
App.xaml.cs
public class App : Application { public override void Initialize() => AvaloniaXamlLoader.Load(this);
ستحتاج إلى إضافة using Avalonia.ReactiveUI
إلى Program.cs
. تأكد من أنه بعد تحديث الحزم ، يبدأ المشروع ويعرض نافذة الترحيب الافتراضية.
# Use .NET Core version which you have installed. # It can be netcoreapp2.0, netcoreapp2.1 and so on. dotnet run --framework netcoreapp3.0

كقاعدة عامة ، هناك طريقتان رئيسيتان لتطبيق التنقل بين صفحات تطبيق .NET - العرض أولاً وعرض الطراز أولاً. يتضمن أسلوب العرض الأول التحكم في مكدس التنقل والتنقل بين الصفحات على مستوى العرض في مصطلحات MVVM - على سبيل المثال ، باستخدام فئتي الإطار والصفحة في حالة UWP أو WPF ، وعند استخدام نهج طريقة العرض الأولى ، يتم تطبيق التنقل على مستوى نماذج العرض التقديمي. تركز أدوات ReactiveUI التي تنظم التوجيه في التطبيق على استخدام أسلوب العرض أولاً. يتكون توجيه ReactiveUI من تطبيق IScreen
يحتوي على حالة جهاز التوجيه ، والعديد من IRoutableViewModel
والتحكم XAML RoutedViewHost
بالنظام الأساسي ، RoutedViewHost
.

يتم تمثيل حالة جهاز التوجيه بواسطة كائن RoutingState
، والذي يتحكم في مكدس التنقل. IScreen
هو أصل مكدس التنقل ، وقد يكون هناك العديد من جذور التنقل في التطبيق. يراقب RoutedViewHost
حالة جهاز التوجيه RoutingState
المطابق ، ويستجيب للتغييرات في مكدس التنقل عن طريق تضمين IRoutableViewModel
المطابق IRoutableViewModel
تحكم XAML. سيتم توضيح الوظيفة الموضحة بالأمثلة أدناه.
حفظ حالة عرض النماذج على القرص
النظر في مثال نموذجي لشاشة البحث عن المعلومات.

يجب أن نقرر ما هي عناصر نموذج تمثيل الشاشة المراد حفظها على القرص أثناء تعليق التطبيق أو إيقاف تشغيله ، وأي منها - لإعادة إنشائه في كل مرة يبدأ فيها. ليست هناك حاجة لحفظ حالة أوامر ReactiveUI التي تنفذ واجهة ICommand
ReactiveCommand<TIn, TOut>
يتم إنشاء وتهيئة ReactiveCommand<TIn, TOut>
في المنشئ ، في حين تعتمد حالة مؤشر CanExecute
على خصائص نموذج العرض ويتم إعادة حسابها عند تغييرها. تعتمد الحاجة إلى حفظ نتائج البحث - نقطة خلافية - على تفاصيل التطبيق ، ولكن سيكون من الحكمة حفظ واستعادة حالة حقل إدخال SearchQuery
!
ViewModels / SearchViewModel.cs
[DataContract] public class SearchViewModel : ReactiveObject, IRoutableViewModel { private readonly ReactiveCommand<Unit, Unit> _search; private string _searchQuery;
يتم تمييز فئة طراز العرض بالسمة [DataContract]
، والخصائص التي تحتاج إلى إجراء تسلسل مع [DataMember]
. هذا يكفي إذا كان المتسلسل المستخدم يستخدم أسلوب التقيد - يحفظ فقط الخصائص التي تم تحديدها صراحة بسمات على القرص ؛ في حالة نهج إلغاء الاشتراك ، من الضروري وضع علامة مع سمات [IgnoreDataMember]
تلك الخصائص التي لا تحتاج إلى حفظها على القرص. بالإضافة إلى ذلك ، نقوم بتطبيق واجهة IRoutableViewModel
في نموذج العرض الخاص بنا بحيث يصبح لاحقًا جزءًا من إطار التنقل IRoutableViewModel
توجيه التطبيق.
وبالمثل ، ننفذ نموذج عرض صفحة التفويضViewModels / LoginViewModel.cs
[DataContract] public class LoginViewModel : ReactiveObject, IRoutableViewModel { private readonly ReactiveCommand<Unit, Unit> _login; private string _password; private string _username;
نماذج العروض التقديمية لصفحتين من التطبيق جاهزة ، IRoutableViewModel
بتنفيذ واجهة IRoutableViewModel
ويمكن دمجها في جهاز توجيه IScreen
. الآن ننفذ IScreen
مباشرة. نحتفل بمساعدة من [DataContract]
السمات التي خصائص نموذج العرض لتسلسل والتي يجب تجاهلها. انتبه إلى المحدد العام للخاصية المميزة بعلامة [DataMember]
، في المثال أدناه - الخاصية مفتوحة عن قصد للكتابة حتى يتسنى للمتسلسل تعديل مثيل تم إنشاؤه حديثًا للكائن أثناء إلغاء تسلسل النموذج.
ViewModels / MainViewModel.cs
[DataContract] public class MainViewModel : ReactiveObject, IScreen { private readonly ReactiveCommand<Unit, Unit> _search; private readonly ReactiveCommand<Unit, Unit> _login; private RoutingState _router = new RoutingState(); public MainViewModel() {
في تطبيقنا ، يحتاج فقط RoutingState
إلى الحفظ على القرص ؛ لأسباب واضحة ، لا تحتاج إلى حفظ الأوامر على القرص - حالتها تعتمد كليا على جهاز التوجيه. يجب أن يتضمن الكائن المسلسل معلومات موسعة حول الأنواع التي تطبق IRoutableViewModel
بحيث يمكن استعادة مكدس التنقل عند إلغاء التسلسل. نحن نصف منطق MainViewModel
عرض MainViewModel
، ViewModels/MainViewModel.cs
الفصل في ViewModels/MainViewModel.cs
وفي مساحة اسم ReactiveUI.Samples.Suspension.ViewModels
المقابلة.

التوجيه في تطبيق أفالونيا
يتم تطبيق منطق واجهة المستخدم على مستوى طبقة النموذج وطراز العرض التقديمي للتطبيق التجريبي ويمكن نقله إلى مجموعة منفصلة موجهة إلى .NET Standard ، لأنه لا يعرف أي شيء عن إطار عمل واجهة المستخدم الرسومية المستخدم. دعنا نلقي نظرة على طبقة العرض التقديمي. في مصطلحات MVVM ، تكون طبقة العرض التقديمي مسؤولة عن تقديم حالة نموذج العرض التقديمي على الشاشة ، ولتقديم الحالة الحالية RoutingState
التوجيه RoutingState
، يتم RoutedViewHost
التحكم XAML RoutedViewHost
المتضمن في حزمة Avalonia.ReactiveUI
. نحن نطبق واجهة المستخدم الرسومية لـ SearchViewModel
- لهذا ، في Views/
الدليل ، ننشئ ملفين: SearchView.xaml
و SearchView.xaml.cs
.
من المحتمل أن يبدو وصف واجهة المستخدم باستخدام لهجة XAML المستخدمة في Avalonia مألوفًا للمطورين في Windows Presentation Foundation أو Universal Windows Platform أو Xamarin.Forms. في المثال أعلاه ، نقوم بإنشاء واجهة تافهة لنموذج البحث - فنحن نرسم مربع نص لإدخال استعلام البحث وزر يبدأ البحث ، بينما نربط عناصر التحكم بخصائص نموذج SearchViewModel
المحدد أعلاه.
المشاهدات / SearchView.xaml
<UserControl xmlns="https://github.com/avaloniaui" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" d:DataContext="{d:DesignInstance viewModels:SearchViewModel}" xmlns:viewModels="clr-namespace:ReactiveUI.Samples.Suspension.ViewModels" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ReactiveUI.Samples.Suspension.Views.SearchView" xmlns:reactiveUi="http://reactiveui.net" mc:Ignorable="d"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="48" /> <RowDefinition Height="48" /> <RowDefinition Height="48" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> <TextBlock Grid.Row="0" Text="Search view" Margin="5" /> <TextBox Grid.Row="1" Text="{Binding SearchQuery, Mode=TwoWay}" /> <Button Grid.Row="2" Content="Search" Command="{Binding Search}" /> </Grid> </UserControl>
المشاهدات / SearchView.xaml.cs
public sealed class SearchView : ReactiveUserControl<SearchViewModel> { public SearchView() {
ستظهر أيضًا التعليمات البرمجية الخلفية SearchView.xaml
التحكم SearchView.xaml
WPF و UWP و XF المألوفين. يتم استخدام استدعاء WhenActivated لتنفيذ بعض التعليمات البرمجية عند تنشيط نموذج العرض أو العرض وإلغاء تنشيطه. إذا كان التطبيق الخاص بك يستخدم الملاحظات الساخنة (الموقتات ، والموقع الجغرافي ، والاتصال DisposeWith
الرسالة) ، فسيكون من الحكمة إرفاقها بـ CompositeDisposable
الاتصال بـ DisposeWith
بحيث عندما تقوم DisposeWith
عنصر تحكم XAML وطراز العرض المقابل له من الشجرة المرئية ، تتوقف الملاحظات الساخنة عن نشر قيم جديدة ولا توجد أي تسربات الذاكرة.
وبالمثل ، نحن ننفذ تمثيل صفحة التفويضالمشاهدات / LoginView.xaml
<UserControl xmlns="https://github.com/avaloniaui" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" d:DataContext="{d:DesignInstance viewModels:LoginViewModel, IsDesignTimeCreatable=True}" xmlns:viewModels="clr-namespace:ReactiveUI.Samples.Suspension.ViewModels" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="ReactiveUI.Samples.Suspension.Views.LoginView" xmlns:reactiveUi="http://reactiveui.net" mc:Ignorable="d"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="48" /> <RowDefinition Height="48" /> <RowDefinition Height="48" /> <RowDefinition Height="48" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> <TextBlock Grid.Row="0" Text="Login view" Margin="5" /> <TextBox Grid.Row="1" Text="{Binding Username, Mode=TwoWay}" /> <TextBox Grid.Row="2" PasswordChar="*" Text="{Binding Password, Mode=TwoWay}" /> <Button Grid.Row="3" Content="Login" Command="{Binding Login}" /> </Grid> </UserControl>
المشاهدات / LoginView.xaml.cs
public sealed class LoginView : ReactiveUserControl<LoginViewModel> { public LoginView() { this.WhenActivated(disposables => { }); AvaloniaXamlLoader.Load(this); } }
قم بتحرير Views/MainView.xaml
Views/MainView.xaml.cs
. RoutedViewHost
XAML RoutedViewHost
من مساحة الاسم RoutedViewHost
على الشاشة الرئيسية ، قم بتعيين حالة جهاز التوجيه RoutingState
إلى خاصية RoutingState
. أضف أزرارًا للانتقال إلى صفحات البحث والترخيص ، وقم ViewModels/MainViewModel
بخصائص ViewModels/MainViewModel
الموضحة أعلاه.
المشاهدات / MainView.xaml
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="ReactiveUI.Samples.Suspension.Views.MainView" xmlns:reactiveUi="http://reactiveui.net" Title="ReactiveUI.Samples.Suspension"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="*" /> <RowDefinition Height="48" /> </Grid.RowDefinitions> <reactiveUi:RoutedViewHost Grid.Row="0" Router="{Binding Router}"> <reactiveUi:RoutedViewHost.DefaultContent> <TextBlock Text="Default Content" /> </reactiveUi:RoutedViewHost.DefaultContent> </reactiveUi:RoutedViewHost> <Grid Grid.Row="1"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*" /> <ColumnDefinition Width="*" /> <ColumnDefinition Width="*" /> </Grid.ColumnDefinitions> <Button Grid.Column="0" Command="{Binding Search}" Content="Search" /> <Button Grid.Column="1" Command="{Binding Login}" Content="Login" /> <Button Grid.Column="2" Command="{Binding Router.NavigateBack}" Content="Back" /> </Grid> </Grid> </Window>
المشاهدات / MainView.xaml.cs
public sealed class MainView : ReactiveWindow<MainViewModel> { public MainView() { this.WhenActivated(disposables => { }); AvaloniaXamlLoader.Load(this); } }
تطبيق بسيط يوضح قدرات التوجيه ReactiveUI و Avalonia جاهز. عند النقر فوق زري Search
وتسجيل Login
، يتم استدعاء الأوامر المقابلة ، ويتم إنشاء مثيل جديد لطراز العرض RoutingState
تحديث RoutingState
. RoutedViewHost
XAML ، RoutedViewHost
، الذي يشترك في التغييرات التي تم إجراؤها على RoutingState
، الحصول على نوع IViewFor<TViewModel>
، حيث يكون TViewModel
هو نوع طراز العرض ، من Locator.Current
. إذا تم العثور على تطبيق مسجل لـ IViewFor<TViewModel>
، فسيتم إنشاء مثيل جديد مدمج في RoutedViewHost
في نافذة تطبيق Avalonia.

نقوم بتسجيل المكونات الضرورية IViewFor<TViewModel>
و IScreen
في طريقة Locator.CurrentMutable
باستخدام Locator.CurrentMutable
. IViewFor<TViewModel>
ضروريًا RoutedViewHost
يعمل RoutedViewHost
، IScreen
تسجيل IScreen
ضروريًا حتى يمكن تهيئة LoginViewModel
و LoginViewModel
بشكل صحيح عند إلغاء التهيئة باستخدام المنشئ بدون معلمات و Locator.Current
.
App.xaml.cs
public override void OnFrameworkInitializationCompleted() {
قم بتشغيل التطبيق وتأكد من أن التوجيه يعمل بشكل صحيح. إذا كان هناك أي أخطاء في علامة XAML ، فإن برنامج التحويل البرمجي XamlIl المستخدم في Avalonia سيخبرنا أين بالضبط ، في مرحلة التجميع. XamlIl كما يدعم تصحيح الأخطاء XAML مباشرة في المصحح IDE !
dotnet run --framework netcoreapp3.0

حفظ واستعادة حالة التطبيق بأكملها
الآن وبعد تكوين التوجيه وتشغيله ، يبدأ الجزء الممتع - تحتاج إلى تطبيق حفظ البيانات على القرص عند إغلاق التطبيق وقراءة البيانات من القرص عند بدء تشغيله ، بالإضافة إلى حالة جهاز التوجيه. تتم معالجة تهيئة الخطافات التي تستمع إلى بدء التطبيق وإغلاق الأحداث بواسطة فئة AutoSuspendHelper
خاصة ، AutoSuspendHelper
بكل نظام أساسي تدعمه ReactiveUI . مهمة المطور هي تهيئة هذه الفئة في بداية جذر تكوين التطبيق. من الضروري أيضًا تهيئة خاصية RxApp.SuspensionHost.CreateNewAppState
وظيفة تُرجع الحالة الافتراضية للتطبيق في حالة عدم وجود حالة محفوظة أو حدث خطأ غير متوقع ، أو في حالة تلف الملف المحفوظ.
بعد ذلك ، تحتاج إلى استدعاء الأسلوب RxApp.SuspensionHost.SetupDefaultSuspendResume
، بتمرير تطبيق ISuspensionDriver
- برنامج التشغيل الذي ISuspensionDriver
ويقرأ كائن الحالة. لتطبيق ISuspensionDriver
، نستخدم مكتبة Newtonsoft.Json
ومساحة الاسم System.IO
للعمل مع نظام الملفات. للقيام بذلك ، قم بتثبيت حزمة Newtonsoft.Json
:
dotnet add package Newtonsoft.Json
Drivers / NewtonsoftJsonSuspensionDriver.cs
public class NewtonsoftJsonSuspensionDriver : ISuspensionDriver { private readonly string _file; private readonly JsonSerializerSettings _settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All }; public NewtonsoftJsonSuspensionDriver(string file) => _file = file; public IObservable<Unit> InvalidateState() { if (File.Exists(_file)) File.Delete(_file); return Observable.Return(Unit.Default); } public IObservable<object> LoadState() { var lines = File.ReadAllText(_file); var state = JsonConvert.DeserializeObject<object>(lines, _settings); return Observable.Return(state); } public IObservable<Unit> SaveState(object state) { var lines = JsonConvert.SerializeObject(state, _settings); File.WriteAllText(_file, lines); return Observable.Return(Unit.Default); } }
— System.IO
Universal Winows Platform, — File
Directory
StorageFile
StorageFolder
. , IRoutableViewModel
, Newtonsoft.Json
TypeNameHandling.All
. Avalonia — App.OnFrameworkInitializationCompleted
:
public override void OnFrameworkInitializationCompleted() {
AutoSuspendHelper
Avalonia.ReactiveUI
IApplicationLifetime
— , ISuspensionDriver
. ISuspensionDriver
appstate.json
:
appstate.json— $type
, , .
{ "$type": "ReactiveUI.Samples.Suspension.ViewModels.MainViewModel, ReactiveUI.Samples.Suspension", "Router": { "$type": "ReactiveUI.RoutingState, ReactiveUI", "_navigationStack": { "$type": "System.Collections.ObjectModel.ObservableCollection`1[[ReactiveUI.IRoutableViewModel, ReactiveUI]], System.ObjectModel", "$values": [ { "$type": "ReactiveUI.Samples.Suspension.ViewModels.SearchViewModel, ReactiveUI.Samples.Suspension", "SearchQuery": "funny cats" }, { "$type": "ReactiveUI.Samples.Suspension.ViewModels.LoginViewModel, ReactiveUI.Samples.Suspension", "Username": "worldbeater" } ] } } }
, , , , , , ! , , ReactiveUI — UWP WPF, Xamarin.Forms.

: ISuspensionDriver
Akavache — UserAccount
Secure
iOS UWP , , Android BundleSuspensionDriver ReactiveUI.AndroidSupport
. JSON Xamarin.Essentials SecureStorage
. , — !
روابط مفيدة