Android実装:Socket双方向通信・ListView無限スクロール・SpinnerカスタムUI

1. Socket通信の仕組みと実装例

Androidアプリがサーバーと通信する方法として、HTTPとSocketの2つがある。HTTPは「リクエスト→レスポンス」の一方向型であり、クライアントから要求があった時だけサーバーが応答を返す。一方、Socketはコネクション確立後、双方向に自由にデータを送受信できる。そのため、サーバー側からクライアントへ能動的にデータをプッシュする用途に適している。Socket(ソケット)はOSが提供する抽象レイヤで、アプリケーションがネットワークを介して他のアプリと通信するための出入口(ポート)を提供する。データ損失が少なく、実装もシンプルという特徴を持つ。

1.1 Socketの分類

TCP/IPプロトコルファミリには2種類の主要Socketタイプがある。
ストリームソケット(TCP):信頼性の高いバイトストリームを提供。データ順序が保証され、再送制御を行う。
データグラムソケット(UDP):パケット単位でデータを送信。コネクションレスで高速だが信頼性は低い。

1.2 サーバー側実装(Javaコンソール)

import java.io.*;
import java.net.*;

public class MyServer {
    public static void main(String[] args) throws Exception {
        ServerSocket serverSocket = new ServerSocket(555);
        System.out.println("サーバー起動、クライアント接続を待機...");
        Socket socket = serverSocket.accept();
        DataInputStream input = new DataInputStream(socket.getInputStream());
        DataOutputStream output = new DataOutputStream(socket.getOutputStream());

        String msg = input.readUTF();
        System.out.println("受信: " + msg);
        output.writeUTF("Server: " + msg);
        output.flush();

        input.close();
        socket.close();
        serverSocket.close();
    }
}

1.3 Androidクライアント実装

public class MainActivity extends Activity {
    private Button sendBtn;
    private TextView resultText;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        sendBtn = (Button) findViewById(R.id.button);
        resultText = (TextView) findViewById(R.id.text);
        sendBtn.setOnClickListener(v -> startSocketTask());
    }

    private void startSocketTask() {
        new Thread(() -> {
            try {
                Socket socket = new Socket("10.20.90.3", 555);
                DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
                DataInputStream dis = new DataInputStream(socket.getInputStream());
                dos.writeUTF("Hello from Android");
                String response = dis.readUTF();
                runOnUiThread(() -> resultText.setText(response));
                dos.close();
                dis.close();
                socket.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }
}

このサンプルではAndroidクライアントがサーバーにメッセージを送信し、サーバーがそのメッセージにプレフィックスを付けて返す。実行前に必ずサーバーを先に起動しておく必要がある。


2. ListViewのスクロール追従によるページング読み込み

大量のデータを表示する際、すべてを一度に読み込むとメモリ負荷が大きい。そこでユーザーがリストの最下部までスクロールしたら、追加データを非同期で取得して末尾に挿入する方式を実装する。

2.1 メインレイアウト(activity_main.xml)

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ListView
        android:id="@+id/listview"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:visibility="gone" />

</RelativeLayout>

2.2 各アイテムのレイアウト(list_item.xml)

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#aa00ff">

    <TextView
        android:id="@+id/textview"
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:textSize="18sp"
        android:textColor="#aa0000"
        android:singleLine="true" />

</RelativeLayout>

2.3 フッター(ローディング表示)レイアウト(footer_loading.xml)

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center">

    <ProgressBar
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="データ読み込み中..."
        android:textSize="18sp"
        android:textColor="#000000" />

</LinearLayout>

2.4 データ生成用クラス(DataProvider.java)

import java.util.ArrayList;
import java.util.List;

public class DataProvider {
    public List<String> getPage(int pageNumber, int pageSize) {
        List<String> items = new ArrayList<>();
        int start = (pageNumber - 1) * pageSize + 1;
        for (int i = 0; i < pageSize; i++) {
            items.add("項目 " + (start + i));
        }
        return items;
    }
}

2.5 メインActivity(ListViewPagingActivity.java)

public class ListViewPagingActivity extends Activity {
    private ListView listView;
    private ArrayAdapter<String> adapter;
    private List<String> data = new ArrayList<>();
    private View footerView;
    private static final int PAGE_SIZE = 30;
    private static final int MAX_PAGES = 5;
    private int currentPage = 1;
    private boolean isLoading = false;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        listView = (ListView) findViewById(R.id.listview);
        footerView = getLayoutInflater().inflate(R.layout.footer_loading, null);

        // 最初のページをロード
        loadPage(1);
        adapter = new ArrayAdapter<>(this, R.layout.list_item, R.id.textview, data);
        listView.addFooterView(footerView);
        listView.setAdapter(adapter);
        listView.removeFooterView(footerView);

        listView.setOnScrollListener(new AbsListView.OnScrollListener() {
            @Override
            public void onScrollStateChanged(AbsListView view, int scrollState) {}

            @Override
            public void onScroll(AbsListView view, int firstVisibleItem,
                                 int visibleItemCount, int totalItemCount) {
                if (totalItemCount == 0) return;
                int lastVisible = firstVisibleItem + visibleItemCount - 1;
                if (lastVisible == totalItemCount - 1 && !isLoading && currentPage < MAX_PAGES) {
                    isLoading = true;
                    listView.addFooterView(footerView);
                    new Thread(() -> {
                        try { Thread.sleep(2000); } catch (InterruptedException e) {}
                        final List<String> newData = new DataProvider().getPage(currentPage + 1, PAGE_SIZE);
                        runOnUiThread(() -> {
                            data.addAll(newData);
                            adapter.notifyDataSetChanged();
                            listView.removeFooterView(footerView);
                            currentPage++;
                            isLoading = false;
                        });
                    }).start();
                }
            }
        });
    }

    private void loadPage(int page) {
        data.addAll(new DataProvider().getPage(page, PAGE_SIZE));
    }
}

この実装ではスクロールが最下部に達した時、新しいデータを非同期で取得し、フッターで「読み込み中」を表示する。下部に到達するたびにページ番号を進め、最大ページに達したらそれ以上読み込まない。


3. Spinner(ドロップダウンリスト)の基本とカスタマイズ

Spinnerは選択肢をドロップダウンまたはダイアログで表示するウィジェット。以下ではシステム標準スタイルとカスタムアダプタを使った例を示す。

3.1 レイアウト(activity_spinner.xml)

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Spinner サンプル"
            android:textSize="25dp"
            android:layout_gravity="center_horizontal" />

        <!-- システム標準ドロップダウン -->
        <Spinner
            android:id="@+id/spinner_std_dropdown"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:entries="@array/cities"
            android:spinnerMode="dropdown" />

        <!-- システム標準ダイアログ -->
        <Spinner
            android:id="@+id/spinner_std_dialog"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:entries="@array/cities"
            android:spinnerMode="dialog" />

        <!-- カスタムアダプタ(ドロップダウン) -->
        <Spinner
            android:id="@+id/spinner_custom_dropdown"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

        <!-- カスタムアダプタ(ダイアログ) -->
        <Spinner
            android:id="@+id/spinner_custom_dialog1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:spinnerMode="dialog" />

        <Spinner
            android:id="@+id/spinner_custom_dialog2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:spinnerMode="dialog" />

        <Button
            android:id="@+id/btn_next"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="次の画面へ" />

    </LinearLayout>
</RelativeLayout>

3.2 res/values/strings.xml

<resources>
    <string-array name="cities">
        <item>選択してください</item>
        <item>東京</item>
        <item>大阪</item>
        <item>名古屋</item>
    </string-array>
</resources>

3.3 メインActivity(SpinnerActivity.java)

public class SpinnerActivity extends Activity {
    private Spinner spinnerCustomDropdown;
    private Spinner spinnerCustomDialog1;
    private Spinner spinnerCustomDialog2;

    private final String[] arrayData = {"項目A", "項目B", "項目C", "項目D"};
    private final List<String> listData = Arrays.asList("選択肢1", "選択肢2", "選択肢3", "選択肢4");

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_spinner);

        spinnerCustomDropdown = (Spinner) findViewById(R.id.spinner_custom_dropdown);
        spinnerCustomDialog1 = (Spinner) findViewById(R.id.spinner_custom_dialog1);
        spinnerCustomDialog2 = (Spinner) findViewById(R.id.spinner_custom_dialog2);

        // カスタムアダプタ1(配列から)
        BaseAdapter adapter1 = new BaseAdapter() {
            @Override
            public int getCount() { return arrayData.length; }

            @Override
            public Object getItem(int position) { return null; }

            @Override
            public long getItemId(int position) { return 0; }

            @Override
            public View getView(int position, View convertView, ViewGroup parent) {
                TextView tv = new TextView(SpinnerActivity.this);
                tv.setText(arrayData[position]);
                tv.setTextSize(18);
                return tv;
            }
        };
        spinnerCustomDropdown.setAdapter(adapter1);

        // カスタムアダプタ2(リストから)
        BaseAdapter adapter2 = new BaseAdapter() {
            @Override
            public int getCount() { return listData.size(); }

            @Override
            public Object getItem(int position) { return null; }

            @Override
            public long getItemId(int position) { return 0; }

            @Override
            public View getView(int position, View convertView, ViewGroup parent) {
                TextView tv = new TextView(SpinnerActivity.this);
                tv.setText(listData.get(position));
                tv.setTextColor(Color.BLUE);
                tv.setTextSize(18);
                return tv;
            }
        };
        spinnerCustomDialog1.setAdapter(adapter2);

        // システム標準ArrayAdapterの例
        ArrayAdapter<String> adapterStd = new ArrayAdapter<>(
                this, android.R.layout.simple_spinner_item, arrayData);
        spinnerCustomDialog2.setAdapter(adapterStd);

        // 選択イベント
        spinnerCustomDialog1.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
            @Override
            public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
                Toast.makeText(SpinnerActivity.this,
                        "選択: " + listData.get(pos), Toast.LENGTH_SHORT).show();
            }

            @Override
            public void onNothingSelected(AdapterView<?> parent) {}
        });

        findViewById(R.id.btn_next).setOnClickListener(v -> {
            startActivity(new Intent(SpinnerActivity.this, NextActivity.class));
        });
    }
}

システム標準のandroid:entriesを使うとXMLだけで簡単に選択肢を設定できる。一方、BaseAdapterを継承して独自のViewを返すことで、フォントサイズや色などを自由にカスタマイズしたSpinnerを作成できる。選択時にOnItemSelectedListenerで処理を追加することも可能。

タグ: Android Socket通信 ListView Spinner BaseAdapter

6月30日 19:11 投稿