Flutter: أساسيات الويدجت
الويدجت هي لبنات بناء Flutter — افهم الويدجت وقد فهمت حمض Flutter النووي.
📋 المتطلبات السابقة: يجب أن تكون قد أكملت ما يلي أولًا
- الدرس 2: دورة مكثفة في لغة Dart
1. ما ستتعلمه
- بنية الأشجار الثلاث Widget-Element-RenderObject وتأثيراتها على الأداء
- مقارنة دورة حياة StatelessWidget مقابل StatefulWidget
- الويدجت الأساسية الشائعة: Text، Image، Icon، Container، Scaffold
- غرض Key وحالات استخدامه: ValueKey / ObjectKey / GlobalKey
- تنفيذ بطاقة منتج ShopApp كـ StatelessWidget
2. قصة حقيقية من مطوّر واجهة أمامية انتقل إلى Flutter
(1) المشكلة: فخ عقلية DOM
انتقل بوب من React إلى Flutter وبغريزة حاول فهم الويدجت عبر نموذج ذهني "المكوّن يعرض DOM". وجد أن بطاقات المنتجات تُعاد بناؤها بشكل متكرر أثناء تمرير القائمة، مما تسبب في بطء صفحة منتجات من 1000 عنصر إلى 30 إطارًا في الثانية. والأكثر إرباكًا أن إضافة key للقائمة جعلت الأداء أسوأ — لأنه كان يستخدم UniqueKey.
(2) حل بنية الأشجار الثلاث
Flutter ليس مجرد تعيين بسيط "مكوّن ← DOM"، بل بنية ثلاثية الطبقات "ويدجت ← عنصر ← RenderObject". الويدجت هي أوصاف إعدادية (خفيفة)، العناصر تدير المثيلات (جوهر الفرق)، وRenderObjects تتولى التخطيط والرسم (ثقيلة). بفهم الأشجار الثلاث، أدرك بوب أنه يجب عليه التحسين على مستوى العنصر عبر الفرق، بدلًا من إعادة البناء على مستوى الويدجت.
import 'package:flutter/material.dart';
// Lightweight widget: just configuration
const ProductCard({required this.product, super.key});
// Flutter internally creates Element and RenderObject
// Widget rebuilds are cheap - Element diffing avoids RenderObject rebuild
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(3) النتيجة: تمرير سلس عند 60 إطارًا في الثانية
بعد التحول إلى ValueKey(product.id)، طابق فرق العنصر العناصر بدقة، وحافظت قائمة المنتجات الـ 1000 على 60 إطارًا مستقرًا في الثانية.
3. الأشجار الثلاث: Widget-Element-RenderObject
graph LR
W[Widget Tree] --> E[Element Tree]
E --> R[RenderObject Tree]
W -.->|canUpdate| E
E -.->|adoptChild| R
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(1) مسؤوليات الأشجار الثلاث
| الطبقة | المنشئ | المسؤولية | الوزن |
|---|---|---|---|
| الويدجت | build() من المطور |
وصف إعدادات غير قابل للتغيير | خفيف جدًا |
| العنصر | إطار العمل تلقائيًا | إدارة بنية الشجرة، فرق التحديثات | متوسط |
| RenderObject | إطار العمل تلقائيًا | القياس، التخطيط، الرسم | ثقيل |
(2) قاعدة canUpdate
يحدد العنصر ما إذا كان سيعيد الاستخدام عبر Widget.canUpdate(oldWidget, newWidget):
import 'package:flutter/material.dart';
// canUpdate logic
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
| السيناريو | runtimeType | key | النتيجة |
|---|---|---|---|
| نفس النوع، نفس المفتاح | نفسه | نفسه | إعادة استخدام العنصر |
| نفس النوع، مفتاح مختلف | نفسه | مختلف | إعادة بناء العنصر |
| نوع مختلف | مختلف | - | إعادة بناء العنصر |
4. الويدجت الأساسية الشائعة
(1) النص والأنماط
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: نص بأنماط مختلفة
import 'package:flutter/material.dart';
// Basic text
const Text('ShopApp')
// Styled text
Text(
'Flash Sale!',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.red,
letterSpacing: 1.5,
),
)
// Rich text with multiple styles
Text.rich(
TextSpan(
text: 'Price: ',
style: const TextStyle(fontSize: 16, color: Colors.grey),
children: [
TextSpan(
text: '\$99.99',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
TextSpan(
text: ' \$149.99',
style: TextStyle(
fontSize: 14,
decoration: TextDecoration.lineThrough,
color: Colors.grey[400],
),
),
],
),
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(2) Container: الحاوية الشاملة
| خاصية Container | الغرض | النوع |
|---|---|---|
padding |
حشو داخلي | EdgeInsets |
margin |
هامش خارجي | EdgeInsets |
decoration |
زخرفة الخلفية | BoxDecoration |
constraints |
قيود | BoxConstraints |
alignment |
محاذاة الويدجت الفرعية | Alignment |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: بطاقة منتج Container
import 'package:flutter/material.dart';
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
'https://cdn.shopapp.com/products/laptop.jpg',
width: 80,
height: 80,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pro Laptop', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text('\$1,299.99', style: const TextStyle(fontSize: 18, color: Colors.green)),
],
),
),
],
),
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(3) الأيقونة وIconButton
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: الأيقونات في سياق التجارة الإلكترونية
import 'package:flutter/material.dart';
// Icon with color and size
const Icon(Icons.shopping_cart, color: Colors.blue, size: 32)
// IconButton for actions
IconButton(
icon: const Icon(Icons.favorite_border),
onPressed: () {
// Add to wishlist
},
tooltip: 'Add to Wishlist',
)
// Badge icon for cart count
Stack(
children: [
const Icon(Icons.shopping_cart, size: 28),
Positioned(
right: 0,
top: 0,
child: Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle),
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
child: const Text('3', style: TextStyle(fontSize: 10, color: Colors.white),
textAlign: TextAlign.center),
),
),
],
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
5. آلية Key
تتحكم المفاتيح في استراتيجية إعادة استخدام العنصر، وهذا أمر بالغ الأهمية خاصة أثناء تحديث القوائم.
(1) أنواع Key
| نوع Key | أساس المقارنة | حالة الاستخدام |
|---|---|---|
ValueKey(value) |
تساوي القيم | عناصر القائمة بمعرفات فريدة (مثل product.id) |
ObjectKey(object) |
مرجع الكائن | مثيلات كائنات فريدة |
UniqueKey() |
مختلف في كل مرة | فرض عدم إعادة الاستخدام (نادرًا ما يُستخدم) |
GlobalKey |
فريد عالميًا | الوصول إلى State عبر الويدجت |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: دور Key في القوائم
import 'package:flutter/material.dart';
// Simplified class definition
class Product {
final int id;
final String name;
final double price;
const Product({required this.id, required this.name, required this.price});
}
// BAD: No key, Flutter matches by position
ListView(
children: [
ProductTile(product: products[0]), // index 0
ProductTile(product: products[1]), // index 1
],
)
// When products reorder, Element mismatches State
// Solution: Use ValueKey with unique id
// GOOD: ValueKey enables correct Element-Widget matching
ListView(
children: [
ProductTile(key: ValueKey(products[0].id), product: products[0]),
ProductTile(key: ValueKey(products[1].id), product: products[1]),
],
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
6. مثال كامل: ويدجت بطاقة منتج ShopApp
import 'package:flutter/material.dart';
class Product {
final int id;
final String name;
final double price;
final String imageUrl;
final double rating;
final int reviewCount;
const Product({
required this.id,
required this.name,
required this.price,
required this.imageUrl,
this.rating = 0.0,
this.reviewCount = 0,
});
}
class ProductCard extends StatelessWidget {
final Product product;
final VoidCallback? onAddToCart;
final VoidCallback? onTap;
const ProductCard({
super.key,
required this.product,
this.onAddToCart,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: Stack(
children: [
Image.network(product.imageUrl, fit: BoxFit.cover,
width: double.infinity),
if (product.price < 50)
Positioned(top: 8, left: 8,
child: _buildBadge('SALE', Colors.red)),
],
),
),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.name, maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text('\$${product.price.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.green, fontSize: 16)),
const Spacer(),
Align(
alignment: Alignment.centerRight,
child: IconButton(
icon: const Icon(Icons.add_shopping_cart, size: 20),
onPressed: onAddToCart,
),
),
],
),
),
),
],
),
),
);
}
Widget _buildBadge(String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(4)),
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 10)),
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
❓ أسئلة شائعة
errorBuilder لعرض صورة بديلة، مع حزمة cached_network_image للتخزين المؤقت ومؤشرات التحميل.📖 ملخص
- بنية ثلاثية الطبقات: الويدجت (إعدادات) ← العنصر (إدارة) ← RenderObject (رسم)
- الويدجت غير قابلة للتغيير وخفيفة، يمكن إعادة بنائها بشكل متكرر؛ العناصر تنفذ الفرق لتقرر ما إذا كانت ستحدث RenderObjects
- المفاتيح تتحكم في استراتيجية إعادة استخدام العنصر: ValueKey يطابق بالقيمة، UniqueKey يفرض عدم إعادة الاستخدام
- Container هي الحاوية الشاملة؛ Scaffold هو هيكل الصفحة
- استخدم ValueKey(product.id) في القوائم لضمان مطابقة العنصر الصحيح
📝 تمارين
- أساسي (الصعوبة ⭐): أنشئ ويدجت
UserAvatarمن StatelessWidget يقبل الاسم وimageUrl، ويعرض صورة دائرية واسم المستخدم. - متوسط (الصعوبة ⭐⭐): نفّذ ويدجت
PriceTagيدعم عرض السعر الأصلي/المخفض، مع السعر المخفض بنص أخضر كبير والسعر الأصلي بخط مشطوب. - متقدم (الصعوبة ⭐⭐⭐): ابنِ قائمة منتجات قابلة للفرز حيث يعتمد إعادة الترتيب بالسحب على ValueKey لضمان تبعية State بشكل صحيح، وبعد الفرز اعرض الترتيب الجديد عبر SnackBar.