مشروع: تطبيق أندرويد متصل بالشبكة
يجمع هذا الدرس الأخير كلَّ مفهوم من مفاهيم الوحدة في تطبيق أندرويد واحد قابل للتشغيل. ستبني تطبيق عناوين الأخبار الذي يجلب المقالات من الواجهة البرمجية العامة NewsAPI، ويخزّنها محليًّا في قاعدة بيانات Room، ويعرضها في RecyclerView. عندما لا يتوفر اتصال بالشبكة، يظل المستخدم يرى آخر البيانات المجلوبة. وعندما يعود الاتصال، يحدّث التطبيق البيانات في الخلفية بصمت.
النمط المستخدم طوال المشروع: نمط المستودع (Repository Pattern) مع استراتيجية مصدر حقيقة وحيد. تقرأ الواجهة دائمًا من Room؛ والمستودع هو من يقرر متى يجلب من الشبكة ويكتب البيانات الجديدة في Room. يمنع هذا الوميض، ويتعامل مع وضع عدم الاتصال بسلاسة، ويبقي كود Activity نظيفًا.
إعداد المشروع
أنشئ مشروع أندرويد جديدًا (Empty Activity، Java، الحد الأدنى لـ SDK هو 21). أضف هذه التبعيات إلى app/build.gradle:
dependencies {
// Retrofit + محوّل Gson
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// Room
implementation 'androidx.room:room-runtime:2.6.1'
annotationProcessor 'androidx.room:room-compiler:2.6.1'
// ViewModel + LiveData
implementation 'androidx.lifecycle:lifecycle-viewmodel:2.7.0'
implementation 'androidx.lifecycle:lifecycle-livedata:2.7.0'
// RecyclerView
implementation 'androidx.recyclerview:recyclerview:1.3.2'
// Glide (تحميل الصور)
implementation 'com.github.bumptech.glide:glide:4.16.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
}
أضف إذن الإنترنت إلى AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
الخطوة الأولى — تعريف كيان Room
يُعيَّن @Entity في Room مباشرةً إلى صف في جدول. خزّن فقط ما تعرضه أو تستعلم عنه — لا تعكس كل حقول الواجهة البرمجية.
// Article.java
@Entity(tableName = "articles")
public class Article {
@PrimaryKey
@NonNull
public String url; // معرّف فريد قادم من الواجهة البرمجية
public String title;
public String source;
public String urlToImage;
public String publishedAt;
public Article(@NonNull String url, String title,
String source, String urlToImage, String publishedAt) {
this.url = url;
this.title = title;
this.source = source;
this.urlToImage = urlToImage;
this.publishedAt = publishedAt;
}
}
الخطوة الثانية — DAO وقاعدة بيانات Room
// ArticleDao.java
@Dao
public interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY publishedAt DESC")
LiveData<List<Article>> getAllArticles();
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertAll(List<Article> articles);
@Query("DELETE FROM articles")
void deleteAll();
}
// AppDatabase.java
@Database(entities = {Article.class}, version = 1, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
private static volatile AppDatabase INSTANCE;
public abstract ArticleDao articleDao();
public static AppDatabase getInstance(Context context) {
if (INSTANCE == null) {
synchronized (AppDatabase.class) {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(
context.getApplicationContext(),
AppDatabase.class,
"news_db"
).build();
}
}
}
return INSTANCE;
}
}
التحقق المزدوج (Double-Checked Locking) في getInstance() هو نمط Singleton الكلاسيكي الآمن للخيوط في أندرويد. الكلمة المفتاحية volatile تمنع JVM من إعادة ترتيب الكتابة قبل اكتمال بناء الكائن.
الخطوة الثالثة — واجهة Retrofit البرمجية
صمّم استجابة JSON بواسطة كائنين بسيطَي POJO، ثم أعلن عن نقطة النهاية:
// NewsResponse.java (يطابق شكل JSON من NewsAPI)
public class NewsResponse {
public List<ArticleDto> articles;
}
// ArticleDto.java
public class ArticleDto {
public String url;
public String title;
public String urlToImage;
public String publishedAt;
public Source source;
public static class Source {
public String name;
}
}
// NewsApiService.java
public interface NewsApiService {
// استبدل YOUR_KEY بمفتاحك المجاني من newsapi.org
@GET("v2/top-headlines?country=us&apiKey=YOUR_KEY")
Call<NewsResponse> getTopHeadlines();
}
// RetrofitClient.java
public class RetrofitClient {
private static Retrofit instance;
public static NewsApiService getService() {
if (instance == null) {
instance = new Retrofit.Builder()
.baseUrl("https://newsapi.org/")
.addConverterFactory(GsonConverterFactory.create())
.build();
}
return instance.create(NewsApiService.class);
}
}
الخطوة الرابعة — المستودع (مصدر الحقيقة الوحيد)
المستودع هو الفئة الوحيدة التي تعرف Room وRetrofit معًا. لا تلمس الواجهة أيًّا منهما مباشرةً.
// ArticleRepository.java
public class ArticleRepository {
private final ArticleDao dao;
private final NewsApiService api;
private final Executor executor = Executors.newSingleThreadExecutor();
public ArticleRepository(Application app) {
dao = AppDatabase.getInstance(app).articleDao();
api = RetrofitClient.getService();
}
/** LiveData التي ترصدها الواجهة — تأتي دائمًا من Room. */
public LiveData<List<Article>> getArticles() {
return dao.getAllArticles();
}
/** يُطلق جلبًا عبر الشبكة في الخلفية ويكتب النتائج في Room. */
public void refresh() {
api.getTopHeadlines().enqueue(new Callback<NewsResponse>() {
@Override
public void onResponse(Call<NewsResponse> call,
Response<NewsResponse> response) {
if (response.isSuccessful() && response.body() != null) {
List<Article> list = mapToEntities(response.body().articles);
executor.execute(() -> {
dao.deleteAll();
dao.insertAll(list);
});
}
}
@Override
public void onFailure(Call<NewsResponse> call, Throwable t) {
// الشبكة غير متاحة — Room لا تزال تحتفظ بآخر البيانات المخزّنة
Log.w("ArticleRepository", "Fetch failed: " + t.getMessage());
}
});
}
private List<Article> mapToEntities(List<ArticleDto> dtos) {
List<Article> result = new ArrayList<>();
for (ArticleDto dto : dtos) {
result.add(new Article(
dto.url,
dto.title,
dto.source != null ? dto.source.name : "Unknown",
dto.urlToImage,
dto.publishedAt
));
}
return result;
}
}
الخطوة الخامسة — ViewModel
// ArticleViewModel.java
public class ArticleViewModel extends AndroidViewModel {
private final ArticleRepository repository;
public final LiveData<List<Article>> articles;
public ArticleViewModel(Application app) {
super(app);
repository = new ArticleRepository(app);
articles = repository.getArticles();
}
public void refresh() {
repository.refresh();
}
}
الخطوة السادسة — محوّل RecyclerView
// ArticleAdapter.java
public class ArticleAdapter extends RecyclerView.Adapter<ArticleAdapter.VH> {
private List<Article> data = Collections.emptyList();
public void submitList(List<Article> list) {
data = list;
notifyDataSetChanged();
}
@NonNull @Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_article, parent, false);
return new VH(v);
}
@Override
public void onBindViewHolder(@NonNull VH holder, int position) {
Article a = data.get(position);
holder.title.setText(a.title);
holder.source.setText(a.source);
Glide.with(holder.image.getContext())
.load(a.urlToImage)
.placeholder(R.drawable.ic_placeholder)
.into(holder.image);
}
@Override public int getItemCount() { return data.size(); }
static class VH extends RecyclerView.ViewHolder {
TextView title, source;
ImageView image;
VH(View v) {
super(v);
title = v.findViewById(R.id.tvTitle);
source = v.findViewById(R.id.tvSource);
image = v.findViewById(R.id.ivThumbnail);
}
}
}
الخطوة السابعة — MainActivity
// MainActivity.java
public class MainActivity extends AppCompatActivity {
private ArticleViewModel viewModel;
private ArticleAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
RecyclerView rv = findViewById(R.id.recyclerView);
rv.setLayoutManager(new LinearLayoutManager(this));
adapter = new ArticleAdapter();
rv.setAdapter(adapter);
SwipeRefreshLayout srl = findViewById(R.id.swipeRefresh);
viewModel = new ViewModelProvider(this).get(ArticleViewModel.class);
viewModel.articles.observe(this, articles -> {
adapter.submitList(articles);
srl.setRefreshing(false);
});
srl.setOnRefreshListener(() -> viewModel.refresh());
// جلب أولي عند أول تشغيل
viewModel.refresh();
}
}
لا تُجرِ عمليات قاعدة بيانات Room على الخيط الرئيسي أبدًا. ترمي Room استثناءً إن حاولت ذلك. استخدم دائمًا خيطًا في الخلفية (Executor أو Thread). في هذا المشروع يستخدم المستودع Executor مخصصًا لجميع عمليات الكتابة عبر DAO.
كيف تسير البيانات من البداية إلى النهاية
- يستدعي
MainActivity.onCreate() الدالةَ viewModel.refresh().
- يُفوّض ViewModel إلى
repository.refresh() الذي يستدعي Retrofit في الخلفية عبر enqueue().
- حين تصل الاستجابة، يحذف المستودع الصفوف القديمة ويُدرج الجديدة في Room عبر خيط
Executor.
- تُخطر Room الـ
LiveData التي أعادها dao.getAllArticles().
- يُسلّم رد الاستدعاء
observe() على الخيط الرئيسي القائمةَ الجديدة إلى المحوّل، فيحدّث RecyclerView.
إن فشلت الخطوة الثانية (لا شبكة)، لن تُنفَّذ الخطوة الثالثة إطلاقًا، وتواصل Room عرض البيانات السابقة — دون الحاجة إلى أي كود إضافي للتعامل مع وضع عدم الاتصال.
الخلاصة
لقد بنيت خط أنابيب بيانات احترافيًّا في أندرويد بلغة Java: يجلب Retrofit، ويثبّت Room، وينقل LiveData، ويصمد ViewModel أمام تغييرات الإعداد، وتبقى Activity في أدنى حد ممكن من التعقيد. هذه المعمارية — التي تُعرف أحيانًا بـ MVVM مع Repository — تتوسّع لأي حجم من التطبيقات وهي الأساس الذي توصي به دليل معمارية أندرويد الرسمي. من هنا يمكنك إضافة التصفح الصفحي (pagination)، أو WorkManager للتحديث الدوري في الخلفية، أو DiffUtil لتحديثات القائمة بكفاءة دون استدعاء notifyDataSetChanged().