MyRecorder(仿微信,录制音频并发送功能)

①布局实现(activity_main.xml)布局采用线性布局,上面使用的一个ListView,下面使用的是一个自定义的Button(会在下面进行介绍)

xmlns:android="http://schemas.android.com/apk/res/android"

xmlns:tools="http://schemas.android.com/tools"

android:layout_width="match_parent"

android:layout_height="match_parent"

android:orientation="vertical"

tools:context="com.yitong.myrecorder.MainActivity">

android:id="@+id/main_listview"

android:layout_width="match_parent"

android:layout_height="0dp"

android:layout_weight="1"

android:background="#ebebeb"

android:dividerHeight="10dp"

android:divider="@null"

/>

android:layout_width="match_parent"

android:layout_height="wrap_content"

android:background="#fff">

android:id="@+id/main_btn"

android:layout_width="match_parent"

android:layout_height="wrap_content"

android:gravity="center"

android:layout_marginTop="6dp"

android:layout_marginBottom="6dp"

android:layout_marginLeft="60dp"

android:layout_marginRight="60dp"

android:minHeight="0dp"

android:padding="6dp"

android:text="@string/str_recoder_normal"

android:textSize="20sp"

android:textColor="#727272"

android:background="@drawable/btn_recorder_normal"

/>

android:layout_width="match_parent"

android:layout_height="1dp"

android:background="#ccc"/>

相关使用的string值(需要添加到value/string中):

按住说话

松开结束

松开手指,取消发送

手指上滑,取消发送

手指上滑,取消发送

录音时间过短

②我们分析一下自定Button的几种状态:

1.正常状态(在初次显示,即没有点击的时候显示的状态,显示的文本为“按住说话”)

2.录音状态(当手指按在Button上时,即为录音状态,显示的文本为“松开结束”)

3.取消状态(当手指上滑,此时若松开手指,便取消发送,即为取消状态,显示的文本为“松开手指,取消发送”)

③当录音状态时,在View上有一个Dialog的提示,首先我们先自定义这个Dialog的布局:

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:background="@drawable/dialog_bg"

android:orientation="vertical"

android:padding="20dp">

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:layout_gravity="center_horizontal"

android:orientation="horizontal">

android:id="@+id/main_recorder_dialog_icon"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:src="@mipmap/recorder"

android:visibility="visible"/>

android:id="@+id/main_recorder_dialog_voice"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:src="@mipmap/v1"

android:visibility="visible"/>

android:id="@+id/main_recorder_dialog_label"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:layout_gravity="center_horizontal"

android:layout_marginTop="5dp"

android:text="@string/str_dialog_want_cancel"

android:textColor="#fff"

android:textSize="20sp"/>

其中用到的@drawable/dialog_bg即为自定的shape

android:shape="rectangle">

android:width="1dp"

android:color="#9b9b9b"/>

④定义DialogManager,便于对这个自定义布局的Dialog进行操作

public class DialogManager {

private static final String TAG = "DialogManager";

private Dialog mDialog;

private ImageView mIcon;

private ImageView mVoice;

private TextView mLabel;

private Context mContext;

public DialogManager(Context mContext) {

this.mContext = mContext;

}

/**

* 显示对话框

*/

public void showRecordeingDialog() {

mDialog = new Dialog(mContext, R.style.Theme_AudioDialog);

LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(mContext.LAYOUT_INFLATER_SERVICE);

View view = inflater.inflate(R.layout.dialog, null);

mDialog.setContentView(view);

mIcon = (ImageView) mDialog.findViewById(R.id.main_recorder_dialog_icon);

mVoice = (ImageView) mDialog.findViewById(R.id.main_recorder_dialog_voice);

mLabel = (TextView) mDialog.findViewById(R.id.main_recorder_dialog_label);

mDialog.show();

}

/**

* 正在录制提示

*/

public void recording() {

if (mDialog != null && mDialog.isShowing()) {

mIcon.setVisibility(View.VISIBLE);

mVoice.setVisibility(View.VISIBLE);

mLabel.setVisibility(View.VISIBLE);

mIcon.setImageResource(R.mipmap.recorder);

mLabel.setText(R.string.str_dialog_want_send);

}

}

/**

* 取消录制对话框提示

*/

public void wantToCancel() {

if (mDialog != null && mDialog.isShowing()) {

mIcon.setVisibility(View.VISIBLE);

mVoice.setVisibility(View.VISIBLE);

mLabel.setVisibility(View.VISIBLE);

mIcon.setImageResource(R.mipmap.recorder);

mLabel.setText(R.string.str_recorder_want_cancel);

}

}

/**

* 录音时间过短提示

*/

public void tooShort() {

if (mDialog != null && mDialog.isShowing()) {

mIcon.setVisibility(View.VISIBLE);

mVoice.setVisibility(View.VISIBLE);

mLabel.setVisibility(View.VISIBLE);

mIcon.setImageResource(R.mipmap.recorder);

mLabel.setText(R.string.str_dialog_time_short);

}

}

/**

* 取消对话框

*/

public void dismissDialog() {

if (mDialog != null && mDialog.isShowing()) {

mDialog.dismiss();

mDialog = null;

}

}

/**

* 显示音量大小

*/

public void updateVoiceLevel(int level) {

if (mDialog != null && mDialog.isShowing()) {

int resId = mContext.getResources().getIdentifier("v" + level, "mipmap", mContext.getPackageName());

mVoice.setImageResource(resId);

}

}

}

Dialog的样式Theme_AudioDialog,需要在values/styles.xml中定义

@android:color/transparent

@null

true

true

false

⑤当手指按住Button时,便开始录音,所以我们还需要定义一个录音的管理类AudioManager来控制录制状态。

public class AudioManager {

private MediaRecorder mMediaRecorder;

private String mDir;// 保存的目录

private String mCurrentFilePath;// 保存音频文件的全路径

private boolean isPrepared = false;// 是否准备完毕

private AudioManager(String dir) {

mDir = dir;

}

private static AudioManager mInstance;

public static AudioManager getmInstance(String mDir) {

if (mInstance == null) {

synchronized (AudioManager.class) {

if (mInstance == null) {

mInstance = new AudioManager(mDir);

}

}

}

return mInstance;

}

/**

* 准备完毕的回调

*/

public interface AudioStateListener {

void wellPrepared();

}

private AudioStateListener mListener;

public void setAudioStateListener(AudioStateListener listener) {

mListener = listener;

}

/** 准备录制 */

public void prepareAudio() {

try {

isPrepared = false;

File dir = new File(mDir);

if (!dir.exists()) {

dir.mkdirs();

}

String fileName = generateName();

File file = new File(dir, fileName);

mCurrentFilePath = file.getAbsolutePath();

mMediaRecorder = new MediaRecorder();

// 设置输出文件

mMediaRecorder.setOutputFile(mCurrentFilePath);

// 设置音频源为麦克风

mMediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);

// 设置音频格式

mMediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.RAW_AMR);

// 设置音频编码

mMediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB);

mMediaRecorder.prepare();

mMediaRecorder.start();

isPrepared = true;

if (mListener != null) {

mListener.wellPrepared();

}

} catch (Exception e) {

e.printStackTrace();

}

}

/** 获取音量大小 */

public int getVoiceLevel(int maxLevel) {

if (isPrepared) {

try {

//mMediaRecorder.getMaxAmplitude() 1-32767

//注意此处mMediaRecorder.getMaxAmplitude 只能取一次,如果前面取了一次,后边再取就为0了

return ((mMediaRecorder.getMaxAmplitude() * maxLevel) / 32768) + 1;

} catch (Exception e) {

}

}

return 1;

}

/** 保存录音,释放资源 */

public void release() {

if(mMediaRecorder != null) {

mMediaRecorder.stop();

mMediaRecorder.release();

mMediaRecorder = null;

}

}

/** 取消录制 */

public void cancel() {

release();

if(mCurrentFilePath != null) {

File file = new File(mCurrentFilePath);

if(file.exists()) {

file.delete();

mCurrentFilePath = null;

}

}

}

/** 获取录制音频的总路径 */

public String getmCurrentFilePath(){

return mCurrentFilePath;

}

/**

* 生成一个随机名字

*/

private String generateName() {

return UUID.randomUUID().toString() + ".mp3";

}

}

⑥处理完DialogManager和AudioManger后,接着我们回到自定义的Button,即AudioRecorderButton

public class AudioRecorderButton extends Button implements AudioManager.AudioStateListener {

private static final int STATE_NORMAL = 1;//正常状态

private static final int STATE_RECORDING = 2;//录音状态

private static final int STATE_WANT_TO_CANCEL = 3;//取消状态

private static final String TAG = "AudioRecorderButton";

private int mCurState = STATE_NORMAL;//当前状态

private boolean isRecording = false;//是否正在录音

private DialogManager mDialogManger;

private AudioManager mAudioManager;

private boolean mReady = false;//是否触发longClick

private float mTime;//计时

public AudioRecorderButton(Context context) {

this(context, null);

}

public AudioRecorderButton(Context context, AttributeSet attrs) {

this(context, attrs, 0);

}

public AudioRecorderButton(Context context, AttributeSet attrs, int defStyleAttr) {

super(context, attrs, defStyleAttr);

mDialogManger = new DialogManager(getContext());

String dir = Environment.getExternalStorageDirectory() + "/my_recorder_audios";

mAudioManager = AudioManager.getmInstance(dir);

mAudioManager.setAudioStateListener(this);

setOnLongClickListener(new OnLongClickListener() {

@Override

public boolean onLongClick(View v) {

mReady = true;

mAudioManager.prepareAudio();

return false;

}

});

}

@Override

public boolean onTouchEvent(MotionEvent event) {

int x = (int) event.getX();

int y = (int) event.getY();

switch (event.getAction()) {

case MotionEvent.ACTION_DOWN:

changeSate(STATE_RECORDING);

break;

case MotionEvent.ACTION_MOVE:

if (isRecording) {

if (isCancelRecorder(x, y)) {

changeSate(STATE_WANT_TO_CANCEL);

} else {

changeSate(STATE_RECORDING);

}

}

break;

case MotionEvent.ACTION_UP:

if (!mReady) {

reset();

return super.onTouchEvent(event);

}

if (!isRecording || mTime < 0.6f) {

mDialogManger.tooShort();

mAudioManager.cancel();

mHandler.sendEmptyMessageDelayed(MSG_LODING_DISMISS, 1000);

} else if (mCurState == STATE_RECORDING) {//正常录制结束

mDialogManger.dismissDialog();

mAudioManager.release();

if (mListener != null) {

mListener.onFinish(mTime, mAudioManager.getmCurrentFilePath());

}

} else if (mCurState == STATE_WANT_TO_CANCEL) {

mDialogManger.dismissDialog();

mAudioManager.cancel();

}

reset();

break;

}

return super.onTouchEvent(event);

}

/**

* 根据不同状态,更改不同的文字和显示的背景

*/

private void changeSate(int stateRecording) {

if (mCurState != stateRecording) {

mCurState = stateRecording;

switch (mCurState) {

case STATE_NORMAL:

setBackgroundResource(R.drawable.btn_recorder_normal);

setText(R.string.str_recoder_normal);

break;

case STATE_RECORDING:

setBackgroundResource(R.drawable.btn_recording);

setText(R.string.str_recorder_recording);

if (isRecording) {

mDialogManger.recording();

}

break;

case STATE_WANT_TO_CANCEL:

setBackgroundResource(R.drawable.btn_recording);

setText(R.string.str_recorder_want_cancel);

mDialogManger.wantToCancel();

break;

}

}

}

/**

* 根据移动后的位置,判断是否取消录音

*/

private boolean isCancelRecorder(int x, int y) {

if (x < 0 || x > getWidth() || y < 0 || y > getHeight()) {

return true;

}

return false;

}

/**

* 重置标识位

*/

private void reset() {

changeSate(STATE_NORMAL);

isRecording = false;

mReady = false;

mTime = 0;

}

/**

* 开始播放时回调此方法

*/

@Override

public void wellPrepared() {

mHandler.sendEmptyMessage(MSG_AUDIO_PREPARED);

}

private static final int MSG_AUDIO_PREPARED = 0x110;

private static final int MSG_VOICE_CHAGE = 0x111;

private static final int MSG_LODING_DISMISS = 0x112;

private Handler mHandler = new Handler() {

@Override

public void handleMessage(Message msg) {

super.handleMessage(msg);

switch (msg.what) {

case MSG_AUDIO_PREPARED:

mDialogManger.showRecordeingDialog();

isRecording = true;

new Thread(mGetVoiceLevelRunnable).start();

break;

case MSG_VOICE_CHAGE:

mDialogManger.updateVoiceLevel(mAudioManager.getVoiceLevel(7));

break;

case MSG_LODING_DISMISS:

mDialogManger.dismissDialog();

break;

}

}

};

/**

* 获取音量大小,并计时

*/

private Runnable mGetVoiceLevelRunnable = new Runnable() {

@Override

public void run() {

while (isRecording) {

SystemClock.sleep(100);

mTime += 0.1f;

mHandler.sendEmptyMessage(MSG_VOICE_CHAGE);

}

}

};

/**

* 完成录制后的回调接口

*/

public interface AudioFinishRecorderListener {

void onFinish(float time, String filePath);

}

private AudioFinishRecorderListener mListener;

public void setAudioFinishRecorderListener(AudioFinishRecorderListener listener) {

mListener = listener;

}

}

=====================至此自定义Button就定义完===================================

①接着我们回到了MainActivity,我们需要获取ListView和AudioRecorderButton组件。对于ListView,需要定义Adapter,当点击某个条目的需要把录制的音频播放出来,需要定义一个MediaManager来控制音频的播放。

②首先我们先定义RecorderAdapter

/**

* 音频实体类,包含音频的长度和保存的路径

*/

public class Recorder implements Serializable {

private int time;

private String filePath;

public Recorder() {

}

public Recorder(int time, String filePath) {

this.time = time;

this.filePath = filePath;

}

public void setTime(int time) {

this.time = time;

}

public void setFilePath(String filePath) {

this.filePath = filePath;

}

public float getTime() {

return time;

}

public String getFilePath() {

return filePath;

}

}

/**

* 继承ArrayAdater,重写getView方法

*/

public class RecorderAdapter extends ArrayAdapter {

private List mDatas;

private Context mContext;

private LayoutInflater mInfalter;

private int mMinItemWidhth;

private int mMaxItemWidhth;

public RecorderAdapter(Context context, List datas) {

super(context, -1, datas);

mDatas = datas;

mContext = context;

mInfalter = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);

DisplayMetrics outMetrics = new DisplayMetrics();

wm.getDefaultDisplay().getMetrics(outMetrics);

mMaxItemWidhth = (int) (outMetrics.widthPixels * 0.7f);

mMinItemWidhth = (int) (outMetrics.widthPixels * 0.15f);

}

@Override

public View getView(int position, View convertView, ViewGroup parent) {

ViewHolder holder = new ViewHolder();

if(convertView == null) {

convertView = mInfalter.inflate(R.layout.item_recorder, null);

}

holder = holder.getHolder(convertView);

holder.setView(holder, mDatas.get(position));

return convertView;

}

private class ViewHolder{

TextView time;

View length;

public ViewHolder getHolder(View view){

ViewHolder holder = (ViewHolder) view.getTag();

if(holder == null) {

holder = new ViewHolder();

}

holder.time = (TextView) view.findViewById(R.id.item_recorder_time);

holder.length = view.findViewById(R.id.item_recorder_length);

view.setTag(holder);

return holder;

}

public void setView(ViewHolder holder, Recorder recorder) {

holder.time.setText(recorder.getTime() + "\"");

ViewGroup.LayoutParams layoutParams = holder.length.getLayoutParams();

layoutParams.width = (int) (mMinItemWidhth + (mMaxItemWidhth / 60f * recorder.getTime()));

}

}

}

③定义MediaManger,用于播放音频

public class MediaManager {

private static MediaPlayer mMediaPlayer;

private static boolean isPause = false;//是否是暂停

/**

* 播放音频

*/

public static void playSound(String filePath, MediaPlayer.OnCompletionListener onCompletionListener) {

if (mMediaPlayer == null) {

mMediaPlayer = new MediaPlayer();

mMediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() {

@Override

public boolean onError(MediaPlayer mp, int what, int extra) {

mMediaPlayer.reset();

return false;

}

});

} else {

mMediaPlayer.reset();

}

try {

mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);

mMediaPlayer.setOnCompletionListener(onCompletionListener);

mMediaPlayer.setDataSource(filePath);

mMediaPlayer.prepare();

mMediaPlayer.start();

} catch (Exception e) {

e.printStackTrace();

}

}

/**

* 暂停

*/

public static void pause() {

if (mMediaPlayer != null && mMediaPlayer.isPlaying()) {

mMediaPlayer.pause();

isPause = true;

}

}

/**

* 继续

*/

public static void resume() {

if (mMediaPlayer != null && isPause) {

mMediaPlayer.start();

isPause = false;

}

}

/**

* 释放资源

*/

public static void release() {

if (mMediaPlayer != null) {

mMediaPlayer.release();

mMediaPlayer = null;

}

}

}

④MainActivity的实现

public class MainActivity extends AppCompatActivity {

private static final String TAG = "MainActivity";

private List mDatas = new ArrayList();

private AudioRecorderButton mAudioRecorderButton;

private ListView mListView;

private RecorderAdapter mAdapter;

private View mAnimView;

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

initView();

initAction();

}

private void initView() {

mAudioRecorderButton = (AudioRecorderButton) findViewById(R.id.main_btn);

mListView = (ListView) findViewById(R.id.main_listview);

}

private void initAction() {

mAudioRecorderButton.setAudioFinishRecorderListener(new AudioRecorderButton.AudioFinishRecorderListener() {

@Override

public void onFinish(float time, String filePath) {

Recorder recorder = new Recorder((int)time, filePath);

mDatas.add(recorder);

mAdapter.notifyDataSetChanged();

mListView.setSelection(mDatas.size() - 1);

}

});

mAdapter = new RecorderAdapter(this, mDatas);

mListView.setAdapter(mAdapter);

mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {

@Override

public void onItemClick(AdapterView> parent, View view, int position, long id) {

// 播放帧动画

mAnimView = view.findViewById(R.id.item_anim);

mAnimView.setBackgroundResource(R.drawable.play_anim);

AnimationDrawable anim = (AnimationDrawable) mAnimView.getBackground();

anim.start();

// 播放音频

MediaManager.playSound(mDatas.get(position).getFilePath(), new MediaPlayer.OnCompletionListener() {

@Override

public void onCompletion(MediaPlayer mp) {

mAnimView.setBackgroundResource(R.mipmap.adj);

}

});

}

});

}

@Override

protected void onPause() {

super.onPause();

MediaManager.pause();

}

@Override

protected void onResume() {

super.onResume();

MediaManager.resume();

}

@Override

protected void onDestroy() {

super.onDestroy();

MediaManager.release();

}

}

帧动画play_anim定义在drawable下

>

android:drawable="@mipmap/v_anim1"

android:duration="300"/>

android:drawable="@mipmap/v_anim2"

android:duration="300"/>

android:drawable="@mipmap/v_anim3"

android:duration="300"/>

⑤最后,不要忘了添加权限

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持脚本之家。

android 语音发送功能,Android仿微信、录制音频并发送功能相关推荐

  1. android 仿微信聊天界面 以及语音录制功能,Android仿微信录制语音功能

    本文实例为大家分享了Android仿微信录制语音的具体代码,供大家参考,具体内容如下 前言 我把录音分成了两部分 1.UI界面,弹窗读秒 2.一个类(包含开始.停止.创建文件名功能) 第一部分 由于6 ...

  2. Android 仿微信录制短视频(不使用 FFmpeg)

    转载请标明出处与作者:https://blog.csdn.net/u011133887/article/details/83654724 项目中原本就有录制短视频的功能,使用的是 # qdrzwd/V ...

  3. Android之---高仿微信录制小视频(拍摄和查看)

    高仿微信录制小视频(拍摄和查看) Android仿微信小视频录制功能 http://blog.csdn.net/u012227600/article/details/50835633 Android仿 ...

  4. Android 高仿微信 你可能要发送的图片

    微信发送图片有个功能,就是当你拍完照片,或者保存一个张照片的时候,你点击聊天框的"+"号,微信会有个提示,你可能要发送的图片,并且附上相应的图片.要实现这个功能,分两个步骤. 1, ...

  5. Android仿微信录制短视频

    WxRecoderVideo 简介 基于VCamera,Android仿微信录制短视频,如果喜欢请star,如果觉得有纰漏请提交issue,如果你有更好的点子可以提交pull request. 使用 ...

  6. android高仿微信小视频,Android仿微信录制小视频

    本文实例为大家分享了Android仿微信录制小视频的具体代码,供大家参考,具体内容如下 先上张图片看看效果 简单叙述下 首先通过Camera类调用系统相机 通过surfaceview绘制出来 通过Me ...

  7. Android 使用 CameraX 快速实现仿微信短视频录制

    Android 使用 CameraX 快速实现仿微信短视频录制(轻触拍照.长按录像) https://github.com/ldlywt/MyCameraX 微信短视频android端 https:/ ...

  8. Android 二维码扫描(仿微信界面),根据Google zxing

    Android 二维码扫描(仿微信界面),根据Google zxing Android项目开发中经常会用到二维码扫描,例如登陆.支付等谷歌方面已经有了一个开源库(地址: https://github. ...

  9. 仿 微信/QQ 实现小程序功能 -IOS

    仿 微信/QQ 实现小程序功能 -IOS 1. 需求 首先,来大致看一下 微信/QQ 小程序的功能. Android端: 点击图标进入小程序,会新开一个任务栈,每个小程序一个任务栈(有的机型所有小程序 ...

最新文章

  1. Windows 2000/XP 注册表终极修改(转载)
  2. 科研文献|圈养和类人猿微生物组的共同多样化
  3. python 遍历文件夹下面所有的文件
  4. c# winform笔记
  5. Codeforces Round #744 (Div. 3)【A-D E的题解】
  6. Spring Security-- 验证码功能的实现
  7. 老司机带你重构Android的v4包的部分源码
  8. java中用new创建一个对象的过程解析_【漫画】Java对象的创建和访问全过程详解...
  9. 思科服务器远程管理,Telnet远程访问思科交换机、路由器 TCP协议分析工具
  10. ubuntu 14.04 将用户目录下中文目录修改为英文目录
  11. ocx控件注册和解除注册
  12. Quorum简介部署
  13. Netty in Action (十九) 第九章节 单元测试
  14. RTX 3090 与 Tesla P40与 Tesla M40 深度学习训练速度比较
  15. 云服务器系统结构图,云服务器系统结构图
  16. 【SAP】ABAP开发——ALV展示后字段的下划线连接
  17. IDE工具(42) Alibaba Cloud Toolkit 一键部署插件使用入门
  18. uni-app - 禁用 APP 横屏旋转(用户操作行为)
  19. BGD 通信15-1 150206102 王嘉良 DDS信号发生器
  20. Kali 2.0安装w3af

热门文章

  1. SoO of EIGRP
  2. 计算机网络有什么特征,计算机网络的基本特征有哪些?
  3. 峰任:企业开展网络营销的关键是自身
  4. 什么是虚拟专用服务器?虚拟专用服务器说明
  5. 谷歌Chrome小恐龙代码(自动跳,高跳,无敌,加速)
  6. linux佳能扫描仪驱动安装步骤,在Centos下安装canoscan扫描仪驱动
  7. 使用Dependency Walker和Process Explorer排查软件release版本发布问题(常用工具)
  8. 【jQuery - serializeArray 序列化表单值并转为键值对】
  9. DATABASE Elderly care
  10. filezilla定时上传_FileZilla的使用和注意事项