本專案完整實現一個「朋友圈」核心功能模組,包含:
技術棧:PullToRefreshListView、FinalBitmap 圖片載入、ContentResolver 掃描媒體庫、MediaRecorder/MediaPlayer 視頻處理。
FriendsListActivity)public class FriendsListActivity extends BaseActivity
implements OnRefreshListener<ListView>, PostListener {
private InteractionAdapter mAdapter;
private PullToRefreshListView mRefreshListView;
private int mPage = 0;
private boolean isRefreshing = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.friends_list);
mAdapter = new InteractionAdapter(this);
mAdapter.setListener(this);
mRefreshListView = findViewById(R.id.friends_list);
mRefreshListView.setOnRefreshListener(this);
mRefreshListView.setAdapter(mAdapter);
// 頂部發布按鈕:彈窗選擇 圖片/視頻/文字
findViewById(R.id.btn_topost).setOnClickListener(v -> showPostDialog());
loadData(0); // 首次載入
}
private void showPostDialog() {
new AlertDialog.Builder(this)
.setItems(new String[]{"圖片", "視頻", "文字"}, (dialog, which) -> {
Intent intent = new Intent();
if (which == 0) intent.setClass(this, CreatePostActivity.class);
else intent.setClass(this, RecorderActivity.class); // 視頻/文字暫用同頁
startActivity(intent);
}).show();
}
private void loadData(int page) {
if (isRefreshing) return;
isRefreshing = true;
mPage = page;
FriendsApi.getFriendsList(this, new NetCallBack() {
@Override public void friendslist(ArrayList<Interaction> list) {
mAdapter.setInteractions(list);
mRefreshListView.onRefreshComplete();
isRefreshing = false;
}
@Override public void failed(String msg) { /* 處理失敗 */ }
});
}
@Override public void onRefresh(PullToRefreshBase<ListView> v) { loadData(0); }
@Override public void play(Interaction it) { /* 跳轉播放頁 */ }
}
關鍵點:介面回調模式 (PostListener) 解耦 Adapter 與 Activity;下拉刷新狀態鎖 isRefreshing 防重複請求。
InteractionAdapter) - ViewHolder 模式public class InteractionAdapter extends BaseAdapter implements View.OnClickListener {
// ... 欄位省略 ...
@Override
public View getView(int position, View convertView, ViewGroup parent) {
ViewHolder holder;
if (convertView == null) {
convertView = LayoutInflater.from(mContext).inflate(R.layout.friend_list_item, null);
holder = new ViewHolder(convertView); // 綁定所有子 View
convertView.setTag(holder);
} else holder = (ViewHolder) convertView.getTag();
Interaction it = interactions.get(position);
// 1. 頭像
mFinal.display(holder.avatar, it.avatar, config);
// 2. 文字
holder.title.setText(it.name);
holder.content.setText(it.content);
// 3. 圖片佈局 (0-3 張)
if (it.images != null && !it.images.isEmpty()) {
holder.imageLayout.setVisibility(View.VISIBLE);
holder.imageLayout.setTag(it); // 整組圖片點擊事件
// 根據數量顯示 1-3 張,用 FinalBitmap 載入
}
// 4. 視頻佈局
else if (it.videoPath != null) {
holder.videoLayout.setVisibility(View.VISIBLE);
holder.playBtn.setTag(it);
holder.playBtn.setOnClickListener(this);
}
// 5. 更多操作
holder.moreBtn.setTag(it);
holder.moreBtn.setOnClickListener(this);
return convertView;
}
@Override public void onClick(View v) {
Interaction it = (Interaction) v.getTag();
if (v.getId() == R.id.image_layout) { /* 跳轉圖片瀏覽器 */ }
else if (v.getId() == R.id.btn_play_result) { /* 觸發 play 回調 */ }
else if (v.getId() == R.id.more) { /* 觸發 show 回調 */ }
}
}
亮點:單一 getView 內處理「純文字、多圖、視頻」三種 Item 型態,透過 Visibility.GONE/VISIBLE 切換佈局,避免多型 Item 的 getViewTypeCount 複雜度;圖片載入統一交給 FinalBitmap 管理快取與動畫。
MultipleActivity) - ContentResolver 掃描優化// 子執行緒掃描媒體庫
new Thread(() -> {
Uri uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
String[] proj = {MediaStore.Images.Media.DATA};
String sel = MediaStore.Images.Media.MIME_TYPE + "=? OR " + MediaStore.Images.Media.MIME_TYPE + "=?";
String[] args = {"image/jpeg", "image/png"};
Cursor cursor = getContentResolver().query(uri, proj, sel, args,
MediaStore.Images.Media.DATE_MODIFIED + " DESC");
HashSet<String> dirSet = new HashSet<>(); // 去重資料夾
List<ImageFloder> folders = new ArrayList<>();
while (cursor.moveToNext()) {
String path = cursor.getString(0);
File parent = new File(path).getParentFile();
if (parent == null || !dirSet.add(parent.getAbsolutePath())) continue;
ImageFloder floder = new ImageFloder();
floder.setDir(parent.getAbsolutePath());
floder.setFirstImagePath(path);
floder.setCount(countImagesInDir(parent)); // FilenameFilter 過濾 jpg/png
folders.add(floder);
if (floder.getCount() > maxCount) { maxCount = floder.getCount(); maxDir = parent; }
}
cursor.close();
dirSet = null; // 釋放記憶體
mHandler.sendEmptyMessage(MSG_SCAN_DONE);
}).start();
優化細節:
HashSet 防止同資料夾重複掃描,大幅降低記憶體佔用。_DATA 欄位,減少 Cursor 開銷。RecorderActivity + MovieRecorderView)// 錄製按鈕觸摸監聽
mShootBtn.setOnTouchListener((v, event) -> {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
mRecorderView.record(() -> handler.sendEmptyMessage(MSG_RECORD_DONE));
} else if (event.getAction() == MotionEvent.ACTION_UP) {
if (mRecorderView.getTimeCount() > 1) { // 最短 1 秒
handler.sendEmptyMessage(MSG_RECORD_DONE);
} else {
mRecorderView.getRecordFile().delete(); // 太短刪除
mRecorderView.stop();
Toast.makeText(this, "時間太短", Toast.LENGTH_SHORT).show();
}
}
return true;
});
生命週期管理:onPause/onSaveInstanceState 中呼叫 mRecorderView.stop() 釋放 MediaRecorder,防止資源洩漏導致後續錄製失敗。
RecorderPlayActivity)public class RecorderPlayActivity extends Activity
implements SurfaceHolder.Callback, View.OnClickListener {
private MediaPlayer player;
private SurfaceView surfaceView;
private String path;
@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.recorder_play);
path = getIntent().getStringExtra("path");
surfaceView = findViewById(R.id.play_view);
surfaceView.getHolder().addCallback(this);
surfaceView.getHolder().setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
findViewById(R.id.ib_play).setOnClickListener(this);
}
@Override public void surfaceCreated(SurfaceHolder holder) {
player = new MediaPlayer();
player.setDisplay(holder);
try { player.setDataSource(path); player.prepare(); }
catch (IOException e) { e.printStackTrace(); }
}
@Override public void onClick(View v) {
if (v.getId() == R.id.ib_play) { player.start(); v.setVisibility(View.GONE); }
else if (v.getId() == R.id.play_view) { player.pause(); findViewById(R.id.ib_play).setVisibility(View.VISIBLE); }
}
@Override protected void onDestroy() {
if (player != null) { player.stop(); player.release(); }
super.onDestroy();
}
}
注意:SurfaceHolder.Callback 確保 Surface 準備好再 prepare();onDestroy 必須 release() 釋放硬體解碼器資源。



PhotoPicker (Android 13+) 或 ActivityResultContracts.PickMultipleVisualMedia 簡化權限與掃描邏輯。MediaCodec 或 FFmpeg 進行硬體編碼壓縮,控制上傳體積。完整代碼已上傳 GitHub (連結略),歡迎 Star、Fork 交流改進!